From 3e35c98a46d22d10feb06a3ef73174a54eccb242 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 5 Oct 2026 16:00:12 +0200 Subject: [PATCH] git add loko/assets/static/assets/unified_index.js \ loko/assets/static/assets/unified_map_layers.js \ loko/assets/templates/assets/unified_index.html \ loko/assets/tests_management_zones.py \ loko/assets/views/core.py \ loko/assets/views/management_zones.py git commit -m "feat(assets): integrate management zones into unified assets view - Add management zones layers and table row support in unified index - Replace element type radio filters with 3 checkboxes (assets, locations, management zones) - Add 3rd card in unified add modal for management zone creation - Include centroid coordinates, area, and unified properties in management zones GeoJSON - Add comprehensive integration test suite" --- loko/assets/static/assets/unified_index.js | 270 +++++++++++++++--- .../static/assets/unified_map_layers.js | 34 ++- .../templates/assets/unified_index.html | 44 +-- loko/assets/tests_management_zones.py | 105 +++++++ loko/assets/views/core.py | 15 + loko/assets/views/management_zones.py | 26 +- 6 files changed, 438 insertions(+), 56 deletions(-) diff --git a/loko/assets/static/assets/unified_index.js b/loko/assets/static/assets/unified_index.js index 4446502..21cf7b9 100644 --- a/loko/assets/static/assets/unified_index.js +++ b/loko/assets/static/assets/unified_index.js @@ -19,7 +19,15 @@ var activeCategories = new Set(SELECTED_CATEGORIES || []); var activeModels = new Set(); var modelFilterCollapsed = true; var activeConditionalFilters = {}; -var currentTypeFilter = 'all'; // 'all' | 'locations' | 'assets' +var currentTypeFilter = 'all'; // legacy fallback + +function getActiveTypeFilters() { + const types = []; + if (document.getElementById('type-cb-assets')?.checked) types.push('assets'); + if (document.getElementById('type-cb-locations')?.checked) types.push('locations'); + if (document.getElementById('type-cb-management-zones')?.checked) types.push('management_zones'); + return types; +} var searchTerms = []; var searchMode = 'and'; var shouldFitSearch = false; @@ -658,9 +666,31 @@ function applyInitialPreferences() { // Type filter if (prefs.type_filter) { - currentTypeFilter = prefs.type_filter; - const rb = document.querySelector(`input[name="unified-type-filter"][value="${prefs.type_filter}"]`); - if (rb) rb.checked = true; + if (Array.isArray(prefs.type_filter)) { + const cbA = document.getElementById('type-cb-assets'); + const cbL = document.getElementById('type-cb-locations'); + const cbZ = document.getElementById('type-cb-management-zones'); + if (cbA) cbA.checked = prefs.type_filter.includes('assets'); + if (cbL) cbL.checked = prefs.type_filter.includes('locations'); + if (cbZ) cbZ.checked = prefs.type_filter.includes('management_zones'); + } else if (typeof prefs.type_filter === 'string') { + const cbA = document.getElementById('type-cb-assets'); + const cbL = document.getElementById('type-cb-locations'); + const cbZ = document.getElementById('type-cb-management-zones'); + if (prefs.type_filter === 'all') { + if (cbA) cbA.checked = true; + if (cbL) cbL.checked = true; + if (cbZ) cbZ.checked = true; + } else if (prefs.type_filter === 'locations') { + if (cbA) cbA.checked = false; + if (cbL) cbL.checked = true; + if (cbZ) cbZ.checked = false; + } else if (prefs.type_filter === 'assets') { + if (cbA) cbA.checked = true; + if (cbL) cbL.checked = false; + if (cbZ) cbZ.checked = false; + } + } } // Include no geometry filter @@ -739,7 +769,7 @@ function savePreferences() { models: Array.from(activeModels), conditional_filters: activeConditionalFilters, visible_columns: getVisibleColumnsPreference(), - type_filter: currentTypeFilter, + type_filter: getActiveTypeFilters(), status_filters: statusFilters, validation_status_filters: validationStatusFilters, include_no_geom: includeNoGeom, @@ -784,9 +814,9 @@ $(document).ready(function () { onUncheckAll: function (rows) { rows.forEach(r => { r.state = false; selectedIds.delete(r.id); }); updateSelectionStatus(); }, onClickRow: function (row) { if (!row.lon || !row.lat) return; - const source = row.is_location ? 'locations' : 'assets'; + const source = row.is_management_zone ? 'management_zones' : (row.is_location ? 'locations' : 'assets'); if (typeof highlightMapFeature === 'function') { - highlightMapFeature(map, source, row.id, row.is_location ? locationshighlightState : assetshighlightState); + highlightMapFeature(map, source, row.id, row.is_management_zone ? managementZonesHighlightState : (row.is_location ? locationshighlightState : assetshighlightState)); } }, }); @@ -865,11 +895,13 @@ $(document).ready(function () { renderModelFilters(); }); - // ─── Type radio ──────────────────────────────────────────────────────── + // ─── Type checkboxes ──────────────────────────────────────────────────── - document.querySelectorAll('input[name="unified-type-filter"]').forEach(rb => { - rb.addEventListener('change', function () { - if (this.checked) currentTypeFilter = this.value; + document.querySelectorAll('input[name="filter-element-type"]').forEach(cb => { + cb.addEventListener('change', function () { + applyFilters(); + updateFilterBadge(); + schedulePreferencesSave(); }); }); @@ -884,26 +916,45 @@ $(document).ready(function () { } // init from locked element type - if (PREVENTIVE_ELEMENT_TYPE === 'assets') currentTypeFilter = 'assets'; - else if (PREVENTIVE_ELEMENT_TYPE === 'locations') currentTypeFilter = 'locations'; + if (PREVENTIVE_ELEMENT_TYPE === 'assets') { + const cbA = document.getElementById('type-cb-assets'); + const cbL = document.getElementById('type-cb-locations'); + const cbZ = document.getElementById('type-cb-management-zones'); + if (cbA) cbA.checked = true; + if (cbL) cbL.checked = false; + if (cbZ) cbZ.checked = false; + } else if (PREVENTIVE_ELEMENT_TYPE === 'locations') { + const cbA = document.getElementById('type-cb-assets'); + const cbL = document.getElementById('type-cb-locations'); + const cbZ = document.getElementById('type-cb-management-zones'); + if (cbA) cbA.checked = false; + if (cbL) cbL.checked = true; + if (cbZ) cbZ.checked = false; + } // ─── Add actions ───────────────────────────────────────────────────── const addConfig = getUnifiedAddModalConfig(); const assetThematics = Array.isArray(addConfig.asset_thematics) ? addConfig.asset_thematics : []; const locationThematics = Array.isArray(addConfig.location_thematics) ? addConfig.location_thematics : []; + const managementZoneThematics = Array.isArray(addConfig.management_zone_thematics) ? addConfig.management_zone_thematics : []; const addButton = document.getElementById('btn-add-asset'); const assetThematicSelect = document.getElementById('unified-add-asset-thematic'); const locationThematicSelect = document.getElementById('unified-add-location-thematic'); + const managementZoneThematicSelect = document.getElementById('unified-add-management-zone-thematic'); const chooserEmpty = document.getElementById('unified-add-chooser-empty'); fillSelectWithOptions(assetThematicSelect, assetThematics, assetThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible')); fillSelectWithOptions(locationThematicSelect, locationThematics, locationThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible')); + fillSelectWithOptions(managementZoneThematicSelect, managementZoneThematics, managementZoneThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible')); if (assetThematicSelect) assetThematicSelect.disabled = !assetThematics.length; if (locationThematicSelect) locationThematicSelect.disabled = !locationThematics.length; - if (chooserEmpty) chooserEmpty.classList.toggle('d-none', assetThematics.length > 0 || locationThematics.length > 0); - if (addButton && !assetThematics.length && !locationThematics.length) { + if (managementZoneThematicSelect) managementZoneThematicSelect.disabled = !managementZoneThematics.length; + + const hasAnyAddRights = assetThematics.length > 0 || locationThematics.length > 0 || managementZoneThematics.length > 0; + if (chooserEmpty) chooserEmpty.classList.toggle('d-none', hasAnyAddRights); + if (addButton && !hasAnyAddRights) { addButton.disabled = true; addButton.title = gettext('Vous n\'avez pas de droits de création'); } @@ -962,6 +1013,17 @@ $(document).ready(function () { } }); + document.getElementById('unified-add-management-zone-open')?.addEventListener('click', function () { + const thematicCode = managementZoneThematicSelect ? managementZoneThematicSelect.value : ''; + if (!thematicCode) { + setModalAlert('unified-add-chooser-alert', gettext('Sélectionnez une thématique pour créer une zone de gestion.'), 'warning'); + return; + } + setModalAlert('unified-add-chooser-alert', '', 'info'); + const baseUrl = addConfig.management_zone_create_url || '/assets/management-zones/add/'; + window.location.href = `${baseUrl}?thematic=${encodeURIComponent(thematicCode)}`; + }); + document.getElementById('unifiedAddAssetCategory')?.addEventListener('change', function () { const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || ''; loadUnifiedAssetModels(thematicCode, this.value); @@ -1175,8 +1237,12 @@ $(document).ready(function () { activeModels.clear(); modelFilterCollapsed = true; activeConditionalFilters = {}; - const allRadio = document.getElementById('type-all'); - if (allRadio) { allRadio.checked = true; currentTypeFilter = 'all'; } + const cbA = document.getElementById('type-cb-assets'); + const cbL = document.getElementById('type-cb-locations'); + const cbZ = document.getElementById('type-cb-management-zones'); + if (cbA) cbA.checked = true; + if (cbL) cbL.checked = true; + if (cbZ) cbZ.checked = true; document.querySelectorAll('[name="filter-status"]').forEach(cb => { cb.checked = false; }); document.querySelectorAll('[name="filter-validation-status"]').forEach(cb => { cb.checked = false; }); const includeNoGeomCheckbox = document.getElementById('include-no-geom'); @@ -1860,11 +1926,13 @@ function findUnifiedRowIndex(rowRef) { const rows = $table.bootstrapTable('getData') || []; const isLocation = !!rowRef.is_location; + const isManagementZone = !!rowRef.is_management_zone; const matches = (row) => { if (!!row.is_location !== isLocation) return false; + if (!!row.is_management_zone !== isManagementZone) return false; if (String(row.id) !== String(rowRef.id)) return false; - if (!isLocation) { + if (!isLocation && !isManagementZone) { if (String(row.thematic || '') !== String(rowRef.thematic || '')) return false; if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false; } else { @@ -1878,7 +1946,7 @@ function findUnifiedRowIndex(rowRef) { if (idx !== -1) return idx; // Fallback in case some rows do not expose all discriminators. - idx = rows.findIndex(row => !!row.is_location === isLocation && String(row.id) === String(rowRef.id)); + idx = rows.findIndex(row => !!row.is_location === isLocation && !!row.is_management_zone === isManagementZone && String(row.id) === String(rowRef.id)); return idx; } @@ -1953,6 +2021,37 @@ function collectClickedAssets(clickEvent) { return Array.from(unique.values()); } +function collectClickedManagementZones(clickEvent) { + const layers = ['management-zones-fill', 'management-zones-line'].filter(layerId => map.getLayer(layerId)); + if (!layers.length) return []; + + const rendered = map.queryRenderedFeatures(clickEvent.point, { layers }); + const unique = new Map(); + + rendered.forEach(f => { + const p = f.properties || {}; + const id = f.id ?? p.id; + if (id == null) return; + const key = `zone|${p.thematic || ''}|${id}`; + if (unique.has(key)) return; + unique.set(key, { + id: id, + code: p.code || '', + name: p.name || '', + thematic: p.thematic || '', + thematic_name: f._thematic_name || p.thematic_name || p.thematic || '', + element_type: p.element_type || gettext('Zone de gestion'), + category: p.category || p.zone_type_name || '', + detail_url: p.detail_url || null, + color: p.color || '', + feature_id: id, + is_management_zone: true, + }); + }); + + return Array.from(unique.values()); +} + function collectClickedLocations(clickEvent) { const layers = ['locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].filter(layerId => map.getLayer(layerId)); if (!layers.length) return []; @@ -2016,12 +2115,29 @@ function bindAssetPopupActions(popupNode) { } }); }); + + popupNode.querySelectorAll('.zone-popup-focus-btn').forEach(btn => { + btn.addEventListener('click', function (e) { + e.preventDefault(); + const rowRef = { + id: this.dataset.zoneId, + thematic: this.dataset.thematic, + is_management_zone: true, + }; + focusRowInUnifiedTable(rowRef); + + if (typeof highlightMapFeature === 'function') { + highlightMapFeature(map, 'management_zones', rowRef.id, managementZonesHighlightState); + } + }); + }); } function showAssetClickPopup(clickEvent) { const assets = collectClickedAssets(clickEvent); const locations = collectClickedLocations(clickEvent); - if (!assets.length && !locations.length) return; + const managementZones = collectClickedManagementZones(clickEvent); + if (!assets.length && !locations.length && !managementZones.length) return; const maxItems = 8; const visibleAssets = assets.slice(0, maxItems); @@ -2113,10 +2229,56 @@ function showAssetClickPopup(clickEvent) { ` : ''; + const visibleZones = managementZones.slice(0, maxItems); + const hiddenZonesCount = Math.max(0, managementZones.length - visibleZones.length); + + const zoneItemsHtml = visibleZones.map(z => { + const code = escapeHtml(z.code || '-'); + const type = escapeHtml(z.element_type || gettext('Zone de gestion')); + const thematic = escapeHtml(z.thematic_name || z.thematic || ''); + const cat = z.category ? ` • ${escapeHtml(z.category)}` : ''; + const dotHtml = z.color ? `` : ''; + const name = escapeHtml(z.name || ''); + const detailAnchor = z.detail_url + ? `${code}` + : `${code}`; + + return ` +
+
+
${detailAnchor}
+
${dotHtml}${type}${cat}${thematic ? ' - ' + thematic : ''}
+ ${name ? `
${name}
` : ''} +
+ +
+ `; + }).join(''); + + const moreZonesHtml = hiddenZonesCount > 0 + ? `
+${hiddenZonesCount} ${escapeHtml(gettext('autre(s) zone(s)'))}
` + : ''; + + const managementZonesSectionHtml = managementZones.length > 0 + ? ` +
${escapeHtml(gettext('Zones de gestion trouvées'))}: ${managementZones.length}
+
${zoneItemsHtml}
+ ${moreZonesHtml} + ` + : ''; + const popupHtml = `
${assetsSectionHtml} ${locationsSectionHtml} + ${managementZonesSectionHtml}
`; @@ -2307,6 +2469,23 @@ function refreshData() { ); } + // ── Management zones: loaded per active thematic + if (cfg.management_zones_geojson_url) { + if (!('management_zones' in geojsonCache[code])) { + const url = cfg.management_zones_geojson_url + '?thematic=' + encodeURIComponent(code); + fetches.push( + fetchGeojsonFeatures(url, { signal }, signal) + .then(data => { + geojsonCache[code].management_zones = data.features || []; + }) + .catch(err => { + if (err.name === 'AbortError') throw err; + geojsonCache[code].management_zones = []; + }) + ); + } + } + // ── Assets: conditional loading const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code); const prevMode = assetsLoadModes[code] || null; @@ -2382,6 +2561,10 @@ function refreshData() { f._thematic_name = thematicName; allFeatures.push(f); }); + (cache.management_zones || []).forEach(f => { + f._thematic_name = thematicName; + allFeatures.push(f); + }); }); renderModelFilters(); applyFilters(); @@ -2406,19 +2589,27 @@ function applyFilters() { let features = allFeatures.slice(); // Type filter - if (currentTypeFilter === 'locations') { - features = features.filter(f => f.properties && f.properties.is_location); - } else if (currentTypeFilter === 'assets') { - features = features.filter(f => f.properties && !f.properties.is_location); - } + const cbAssets = document.getElementById('type-cb-assets'); + const cbLocations = document.getElementById('type-cb-locations'); + const cbZones = document.getElementById('type-cb-management-zones'); + const showAssets = cbAssets ? cbAssets.checked : true; + const showLocations = cbLocations ? cbLocations.checked : true; + const showManagementZones = cbZones ? cbZones.checked : true; + + features = features.filter(f => { + if (!f.properties) return false; + if (f.properties.is_management_zone) return showManagementZones; + if (f.properties.is_location) return showLocations; + return showAssets; + }); // Include no geom filter const includeNoGeomCheckbox = document.getElementById('include-no-geom'); const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true; if (!includeNoGeom) { features = features.filter(f => { - // Locations are never filtered by geometry checkbox (only assets) - if (f.properties && f.properties.is_location) return true; + // Locations and management zones are never filtered by geometry checkbox (only assets) + if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true; return f.geometry && (f.geometry.coordinates || f.geometry.type); }); } @@ -2435,7 +2626,7 @@ function applyFilters() { const selectedValidationStatuses = Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value); if (selectedValidationStatuses.length) { features = features.filter(f => { - if (f.properties && f.properties.is_location) return true; + if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true; return selectedValidationStatuses.includes(f.properties && f.properties.validation_status); }); } @@ -2443,7 +2634,7 @@ function applyFilters() { // Category filter if (activeCategories.size) { features = features.filter(f => { - if (f.properties && f.properties.is_location) return true; + if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true; return activeCategories.has(getFeatureCategoryValue(f)); }); } @@ -2451,7 +2642,7 @@ function applyFilters() { // Model filter if (activeModels.size) { features = features.filter(f => { - if (f.properties && f.properties.is_location) return true; + if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true; return activeModels.has(getFeatureModelValue(f)); }); } @@ -2519,6 +2710,7 @@ function loadTableData(features) { length: Number.isFinite(length) ? length : 0, area: Number.isFinite(area) ? area : 0, is_location: p.is_location, + is_management_zone: p.is_management_zone || false, }; }); @@ -2539,6 +2731,7 @@ function loadTableData(features) { var locationshighlightState = { previousId: null }; var assetshighlightState = { previousId: null }; +var managementZonesHighlightState = { previousId: null }; function isPanelTextSpriteId(missingId) { @@ -2611,15 +2804,20 @@ function rebuildMapSources() { if (typeof map === 'undefined') return; const locSrc = map.getSource('locations'); const asstSrc = map.getSource('assets'); + const zoneSrc = map.getSource('management_zones'); if (!locSrc || !asstSrc) return; // sources not yet added (before mapLoaded) mergedLocationsGeojson = { type: 'FeatureCollection', features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_location), }; + mergedManagementZonesGeojson = { + type: 'FeatureCollection', + features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_management_zone), + }; mergedAssetsGeojson = { type: 'FeatureCollection', - features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location), + features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location && !f.properties.is_management_zone), }; applyUnifiedSignPanelOffsets(mergedAssetsGeojson); @@ -2627,6 +2825,7 @@ function rebuildMapSources() { locSrc.setData(mergedLocationsGeojson); asstSrc.setData(mergedAssetsGeojson); + if (zoneSrc) zoneSrc.setData(mergedManagementZonesGeojson); } @@ -2642,6 +2841,7 @@ maploader.addEventListener('mapLoaded', () => { data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/' }); map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); + map.addSource('management_zones', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); // Add layers in render order: fills → lines → circles/symbols (see UNIFIED_LAYERS) @@ -2782,10 +2982,10 @@ maploader.addEventListener('mapLoaded', () => { }); // Click interaction on map elements: show popup when assets are found at click position. - ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].forEach(layerId => { + ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline', 'management-zones-fill', 'management-zones-line'].forEach(layerId => { if (!map.getLayer(layerId)) return; map.on('click', layerId, showAssetClickPopup); - if (['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical'].includes(layerId)) { + if (['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'management-zones-fill'].includes(layerId)) { map.on('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; }); } @@ -2795,7 +2995,7 @@ maploader.addEventListener('mapLoaded', () => { map.on('click', function (e) { if (!assetClickPopup || !assetClickPopup.isOpen()) return; - const interactiveLayers = ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'] + const interactiveLayers = ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline', 'management-zones-fill', 'management-zones-line'] .filter(layerId => map.getLayer(layerId)); if (!interactiveLayers.length) { assetClickPopup.remove(); @@ -2856,7 +3056,7 @@ function updateFilterBadge() { }, 0); n += thematicBlocksCount; - if (currentTypeFilter !== 'all') n += 1; + if (getActiveTypeFilters().length < 3) n += 1; if (document.querySelectorAll('[name="filter-status"]:checked').length > 0) n += 1; if (document.querySelectorAll('[name="filter-validation-status"]:checked').length > 0) n += 1; diff --git a/loko/assets/static/assets/unified_map_layers.js b/loko/assets/static/assets/unified_map_layers.js index 1300e76..229e72d 100644 --- a/loko/assets/static/assets/unified_map_layers.js +++ b/loko/assets/static/assets/unified_map_layers.js @@ -384,6 +384,34 @@ const _unified_locations_fill = { }, }; +// ─── Layer 1b — management zones fill ───────────────────────────────────────── +const _unified_management_zones_fill = { + id: 'management-zones-fill', + type: 'fill', + source: 'management_zones', + filter: ['any', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['geometry-type'], 'MultiPolygon'] + ], + paint: { + 'fill-color': ['coalesce', ['get', 'color'], '#10b981'], + 'fill-opacity': 0.25, + 'fill-antialias': false, + }, +}; + +// ─── Layer 3b — management zones line ───────────────────────────────────────── +const _unified_management_zones_line = { + id: 'management-zones-line', + type: 'line', + source: 'management_zones', + paint: { + 'line-color': ['coalesce', ['get', 'color'], '#10b981'], + 'line-width': 2, + 'line-dasharray': [3, 2], + }, +}; + // ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ──── const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer) ? AssetSymbology.getAssetsFillLayer('assets') @@ -1145,10 +1173,12 @@ const UNIFIED_LAYERS = [ _unified_territory_mask, // 0. territory mask (outside commune) _unified_territory_outline, // 0b. territory boundary line _unified_locations_fill, // 1. polygon fills (bottom) + _unified_management_zones_fill, // 1b. management zones fill _unified_assets_fill, // 2. polygon fills _unified_locations_outline, // 3. lines - _unified_assets_line, // 3b. asset lines - _unified_assets_polygon_outline, // 3c. polygon asset outlines + _unified_management_zones_line, // 3b. management zones line + _unified_assets_line, // 3c. asset lines + _unified_assets_polygon_outline, // 3d. polygon asset outlines _unified_locations_circle, // 4. circles _unified_locations_radar_outline, _unified_locations_radar, diff --git a/loko/assets/templates/assets/unified_index.html b/loko/assets/templates/assets/unified_index.html index 0131b92..d661480 100644 --- a/loko/assets/templates/assets/unified_index.html +++ b/loko/assets/templates/assets/unified_index.html @@ -670,24 +670,24 @@
-
-
- - +
+
+ +
-
- - +
+ +
-
- + - +
@@ -837,7 +837,7 @@ {% translate "Choisissez d'abord le type d'élément, puis la thématique autorisée." %}
-
+
{% translate "Asset" %}
@@ -847,7 +847,7 @@
-
+
{% translate "Localisation" %}
@@ -857,6 +857,16 @@
+
+
+
{% translate "Zone de gestion" %}
+ + + +
+
{% translate "Aucune thématique éditable n'est disponible pour la création." %} diff --git a/loko/assets/tests_management_zones.py b/loko/assets/tests_management_zones.py index 81d1605..7101dfe 100644 --- a/loko/assets/tests_management_zones.py +++ b/loko/assets/tests_management_zones.py @@ -486,3 +486,108 @@ class ManagementZonesPermissionAndCRUDTests(TestCase): data = res.json() self.assertEqual(len(data['types']), 1) self.assertEqual(data['types'][0]['code'], 'fauche_eco') + + +class ManagementZoneUnifiedViewIntegrationTests(TestCase): + def setUp(self): + self.thematic_nature = Thematic.objects.create(name_fr="Nature", name_nl="Natuur", code="nature") + self.thematic_roads = Thematic.objects.create(name_fr="Voirie", name_nl="Wegen", code="roads") + + self.user_editor = User.objects.create_user(username="editor_unified", password="password") + self.user_viewer = User.objects.create_user(username="viewer_unified", password="password") + + self.config_editor = UserConfig.objects.create(user=self.user_editor) + self.config_viewer = UserConfig.objects.create(user=self.user_viewer) + + UserThematics.objects.create( + user_config=self.config_editor, + thematic=self.thematic_nature, + can_view_assets=True, + can_edit_assets=True, + can_edit_management_zones=True, + ) + + UserThematics.objects.create( + user_config=self.config_viewer, + thematic=self.thematic_nature, + can_view_assets=True, + can_edit_assets=False, + can_edit_management_zones=False, + ) + + self.zone_type = ManagementZoneType.objects.create( + thematic=self.thematic_nature, + code="fauche_eco", + name_fr="Fauche tardive", + name_nl="Laat maaien", + default_color="#27ae60", + ) + + poly = Polygon(((149000, 170000), (149100, 170000), (149100, 170100), (149000, 170100), (149000, 170000))) + multi_poly = MultiPolygon([poly], srid=3812) + self.zone = ManagementZone.objects.create( + thematic=self.thematic_nature, + zone_type=self.zone_type, + code="ZN-TEST-001", + name_fr="Pelouse A", + name_nl="Grasveld A", + geom=multi_poly, + is_active=True, + created_by=self.user_editor, + ) + + def test_build_thematic_config_includes_management_zones_url(self): + from assets.views.core import build_thematic_config + config = build_thematic_config([self.thematic_nature, self.thematic_roads]) + self.assertIn("nature", config) + self.assertIn("management_zones_geojson_url", config["nature"]) + self.assertEqual(config["nature"]["management_zones_geojson_url"], reverse("assets:management_zones_geojson")) + + def test_build_unified_add_modal_config_permissions(self): + from assets.views.core import build_unified_add_modal_config + # Editor can edit management zones in nature + editor_config = build_unified_add_modal_config([self.thematic_nature], user=self.user_editor) + self.assertIn("management_zone_thematics", editor_config) + editor_codes = [t["code"] for t in editor_config["management_zone_thematics"]] + self.assertIn("nature", editor_codes) + + # Viewer cannot edit management zones + viewer_config = build_unified_add_modal_config([self.thematic_nature], user=self.user_viewer) + viewer_codes = [t["code"] for t in viewer_config["management_zone_thematics"]] + self.assertNotIn("nature", viewer_codes) + + def test_management_zones_geojson_properties_for_unified_table(self): + self.client.login(username="editor_unified", password="password") + url = reverse("assets:management_zones_geojson") + f"?thematic={self.thematic_nature.code}" + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(len(data["features"]), 1) + feat = data["features"][0] + props = feat["properties"] + + self.assertTrue(props["is_management_zone"]) + self.assertFalse(props["is_location"]) + self.assertEqual(props["asset_type"], "managementzone") + self.assertIn(props["element_type"], ["Zone de gestion", "Management zone"]) + self.assertEqual(props["category"], "Fauche tardive") + self.assertEqual(props["status"], "active") + self.assertIsNotNone(props["lon"]) + self.assertIsNotNone(props["lat"]) + self.assertIsNotNone(props["area"]) + self.assertEqual(props["detail_url"], reverse("assets:management_zone_detail", kwargs={"pk": self.zone.pk})) + + def test_unified_index_page_contains_management_zone_elements(self): + self.client.login(username="editor_unified", password="password") + url = reverse("assets:index") + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + + # Check filter modal checkboxes + self.assertContains(res, 'id="type-cb-assets"') + self.assertContains(res, 'id="type-cb-locations"') + self.assertContains(res, 'id="type-cb-management-zones"') + + # Check add modal card + self.assertContains(res, 'id="unified-add-management-zone-thematic"') + self.assertContains(res, 'id="unified-add-management-zone-open"') diff --git a/loko/assets/views/core.py b/loko/assets/views/core.py index f19f646..4d517c9 100644 --- a/loko/assets/views/core.py +++ b/loko/assets/views/core.py @@ -470,6 +470,8 @@ ELEMENT_TYPE_LABELS = { # --- Espaces plein air --- 'outdoorlocation': _('Espace public plein air'), 'outdoorasset': _('Équipement plein air'), + # --- Zones de gestion --- + 'managementzone': _('Zone de gestion'), } @@ -553,6 +555,7 @@ def build_thematic_config(thematics): }, } + management_zones_url = reverse('assets:management_zones_geojson') result = {} for thematic in thematics: code = thematic.code @@ -560,6 +563,7 @@ def build_thematic_config(thematics): entry = dict(_THEMATIC_URLS[code]) entry['name'] = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic) entry['icon'] = thematic.icon if hasattr(thematic, 'icon') else '' + entry['management_zones_geojson_url'] = management_zones_url result[code] = entry return result @@ -936,6 +940,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None): from assets.permissions import ( can_edit_location_for_thematic, get_allowed_actions_for_thematic, + can_edit_management_zones, ) location_type_by_thematic = { @@ -948,6 +953,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None): asset_thematics = [] location_thematics = [] + management_zone_thematics = [] for thematic in accessible_thematics: thematic_name = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic) @@ -974,9 +980,18 @@ def build_unified_add_modal_config(accessible_thematics, user=None): 'location_label': location_meta['label'], }) + if can_edit_management_zones(user, thematic): + management_zone_thematics.append({ + 'code': thematic.code, + 'name': thematic_name, + 'icon': thematic_icon, + }) + return { 'asset_thematics': asset_thematics, 'location_thematics': location_thematics, + 'management_zone_thematics': management_zone_thematics, + 'management_zone_create_url': reverse('assets:management_zone_create'), } diff --git a/loko/assets/views/management_zones.py b/loko/assets/views/management_zones.py index 442aae3..70657cf 100644 --- a/loko/assets/views/management_zones.py +++ b/loko/assets/views/management_zones.py @@ -39,7 +39,7 @@ def management_zones_geojson(request): if not thematic: return JsonResponse({'type': 'FeatureCollection', 'features': []}) - can_view, _ = get_user_asset_thematic_access(user, thematic) + can_view, _can_edit = get_user_asset_thematic_access(user, thematic) if not can_view: return HttpResponseForbidden("Access denied to this thematic management zones.") @@ -77,6 +77,18 @@ def management_zones_geojson(request): if not geom_json: continue + centroid = zone.geom.centroid.clone() if zone.geom else None + if centroid: + if centroid.srid and centroid.srid != 4326: + centroid.transform(4326) + lon = round(centroid.x, 7) + lat = round(centroid.y, 7) + else: + lon = None + lat = None + area = round(zone.geom.area, 1) if getattr(zone.geom, 'area', None) is not None else None + category_name = zone.zone_type.get_name() if zone.zone_type else "" + features.append({ 'type': 'Feature', 'id': zone.id, @@ -87,12 +99,22 @@ def management_zones_geojson(request): 'thematic': zone.thematic.code, 'thematic_name': zone.thematic.name, 'zone_type_code': zone.zone_type.code if zone.zone_type else None, - 'zone_type_name': zone.zone_type.get_name() if zone.zone_type else "", + 'zone_type_name': category_name, + 'category': category_name, 'color': zone.get_color(), 'instructions': zone.instructions or "", 'description': zone.description or "", 'seasonality_notes': zone.seasonality_notes or "", 'target_frequency_days': zone.target_frequency_days, + 'status': 'active' if zone.is_active else 'inactive', + 'element_type': str(_('Zone de gestion')), + 'asset_type': 'managementzone', + 'detail_url': reverse('assets:management_zone_detail', kwargs={'pk': zone.pk}), + 'lon': lon, + 'lat': lat, + 'area': area, + 'is_location': False, + 'is_management_zone': True, }, 'geometry': geom_json })