This commit is contained in:
Karl Determe 2026-10-09 14:33:49 +02:00
commit cd3f3999cb
20 changed files with 679 additions and 41 deletions

View file

@ -0,0 +1,14 @@
# Generated by Django 6.0.7 on 2026-10-09 11:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('assets', '0031_itsgeoasset_extra_data_itsbollardsystem_and_more'),
('assets', '0031_naturetree_amenity_value_and_more'),
]
operations = [
]

View file

@ -261,6 +261,33 @@
glyphCodepoint: 'f30b', glyphCodepoint: 'f30b',
mapImage: 'icon-naturerwiasset', mapImage: 'icon-naturerwiasset',
}, },
naturetree_regional: {
thematic: 'nature',
color: '#1d4ed8',
iconUrl: '/static/assets/nature_tree_icon_regional.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature-regional',
},
naturetree_remarkable: {
thematic: 'nature',
color: '#d97706',
iconUrl: '/static/assets/nature_tree_icon_remarkable.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature-remarkable',
},
naturetree_dead: {
thematic: 'nature',
color: '#6b7280',
iconUrl: '/static/assets/nature_tree_icon_dead.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature-dead',
},
naturetree: { naturetree: {
thematic: 'nature', thematic: 'nature',
color: '#2e7d32', color: '#2e7d32',
@ -578,6 +605,25 @@
} }
// 3. Specific asset_type override // 3. Specific asset_type override
if (assetType === 'naturetree' || (thematic === 'nature' && (assetType === 'naturetree' || !assetType))) {
const isDead = item.is_dead === true || item.is_dead === 'true' || item.is_dead === 1 ||
item.vitality === 'dead' || item.status === 'dead' || item.status === 'archived';
if (isDead && ASSET_TYPE_OVERRIDES.naturetree_dead) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree_dead);
}
const isRemarkable = item.is_remarkable === true || item.is_remarkable === 'true' || item.is_remarkable === 1;
const isRegional = item.management_type === 'regional' ||
(typeof item.managing_authority === 'string' && (item.managing_authority.toLowerCase().includes('région') || item.managing_authority.toLowerCase().includes('mobilité')));
if (isRemarkable && ASSET_TYPE_OVERRIDES.naturetree_remarkable) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree_remarkable);
}
if (isRegional && ASSET_TYPE_OVERRIDES.naturetree_regional) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree_regional);
}
if (ASSET_TYPE_OVERRIDES.naturetree) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree);
}
}
if (assetType === 'naturebench') { if (assetType === 'naturebench') {
const isPlanter = (item.asset_sub_type === 'planter') || const isPlanter = (item.asset_sub_type === 'planter') ||
['Bac à fleurs / Jardinière', 'Bloembak', 'Jardinière', 'Bac à fleurs', 'Bac à plantes'].includes(item.model) || ['Bac à fleurs / Jardinière', 'Bloembak', 'Jardinière', 'Bac à fleurs', 'Bac à plantes'].includes(item.model) ||
@ -625,12 +671,17 @@
const customStyle = options.style ? ` style="${options.style}"` : ''; const customStyle = options.style ? ` style="${options.style}"` : '';
const classList = ['asset-symbology-icon-img'];
if (options.imgClass) classList.push(options.imgClass);
const imgClass = classList.join(' ');
const defaultImgStyle = 'width: 20px; height: 20px; max-width: 20px; max-height: 20px; object-fit: contain; vertical-align: middle; display: inline-block;';
const finalStyle = options.style ? ` style="${defaultImgStyle} ${options.style}"` : ` style="${defaultImgStyle}"`;
if (symbology.iconUrl) { if (symbology.iconUrl) {
const imgClass = options.imgClass || 'asset-symbology-icon-img';
const fallbackUrl = symbology.fallbackUrl || (symbology.iconUrl.includes('.svg') ? symbology.iconUrl.replace(/\.svg(\?.*)?$/, '.png$1') : ''); const fallbackUrl = symbology.fallbackUrl || (symbology.iconUrl.includes('.svg') ? symbology.iconUrl.replace(/\.svg(\?.*)?$/, '.png$1') : '');
const fallbackClass = options.iconClass || symbology.iconClass || 'bi-sign-stop-fill'; const fallbackClass = options.iconClass || symbology.iconClass || 'bi-sign-stop-fill';
const onerrorAttr = ` onerror="this.onerror=null; ${fallbackUrl ? `if(this.src && this.src.indexOf('.svg') !== -1){ this.src='${fallbackUrl}'; } else {` : ''} this.style.display='none'; if(this.nextElementSibling){ this.nextElementSibling.style.display=''; } ${fallbackUrl ? '}' : ''}"`; const onerrorAttr = ` onerror="this.onerror=null; ${fallbackUrl ? `if(this.src && this.src.indexOf('.svg') !== -1){ this.src='${fallbackUrl}'; } else {` : ''} this.style.display='none'; if(this.nextElementSibling){ this.nextElementSibling.style.display=''; } ${fallbackUrl ? '}' : ''}"`;
return `<img src="${symbology.iconUrl}" alt="" class="${imgClass}"${customStyle}${onerrorAttr}><i class="bi ${fallbackClass}" style="display:none;${options.style || ''}"></i>`; return `<img src="${symbology.iconUrl}" alt="" class="${imgClass}"${finalStyle}${onerrorAttr}><i class="bi ${fallbackClass}" style="display:none;${options.style || ''}"></i>`;
} }
const iconClass = options.iconClass || symbology.iconClass || 'bi-pin-map'; const iconClass = options.iconClass || symbology.iconClass || 'bi-pin-map';
@ -653,6 +704,9 @@
publiclighting: injected.publiclighting || THEMATIC_CONFIG.publiclighting.iconUrl, publiclighting: injected.publiclighting || THEMATIC_CONFIG.publiclighting.iconUrl,
publiclightingelectricalcabinet: injected.publiclightingelectricalcabinet || ASSET_TYPE_OVERRIDES.publiclightingelectricalcabinet.iconUrl, publiclightingelectricalcabinet: injected.publiclightingelectricalcabinet || ASSET_TYPE_OVERRIDES.publiclightingelectricalcabinet.iconUrl,
nature: injected.nature || THEMATIC_CONFIG.nature.iconUrl, nature: injected.nature || THEMATIC_CONFIG.nature.iconUrl,
natureregional: injected.natureregional || (ASSET_TYPE_OVERRIDES.naturetree_regional && ASSET_TYPE_OVERRIDES.naturetree_regional.iconUrl),
natureremarkable: injected.natureremarkable || (ASSET_TYPE_OVERRIDES.naturetree_remarkable && ASSET_TYPE_OVERRIDES.naturetree_remarkable.iconUrl),
naturedead: injected.naturedead || (ASSET_TYPE_OVERRIDES.naturetree_dead && ASSET_TYPE_OVERRIDES.naturetree_dead.iconUrl),
structures: injected.structures || THEMATIC_CONFIG.structures.iconUrl, structures: injected.structures || THEMATIC_CONFIG.structures.iconUrl,
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl, roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl, roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
@ -773,6 +827,26 @@
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'], ['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
], 'icon-natureplanter', ], 'icon-natureplanter',
['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench', ['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench',
['all',
['any', ['==', ['get', 'asset_type'], 'naturetree'], ['==', ['get', 'thematic'], 'nature']],
['any',
['==', ['get', 'is_dead'], true],
['==', ['get', 'is_dead'], 'true'],
['==', ['get', 'is_dead'], 1],
['==', ['get', 'status'], 'dead'],
['==', ['get', 'status'], 'archived'],
['==', ['get', 'vitality'], 'dead']
]
], 'icon-nature-dead',
['all',
['any', ['==', ['get', 'asset_type'], 'naturetree'], ['==', ['get', 'thematic'], 'nature']],
['any', ['==', ['get', 'is_remarkable'], true], ['==', ['get', 'is_remarkable'], 'true'], ['==', ['get', 'is_remarkable'], 1]]
], 'icon-nature-remarkable',
['all',
['any', ['==', ['get', 'asset_type'], 'naturetree'], ['==', ['get', 'thematic'], 'nature']],
['any', ['==', ['get', 'management_type'], 'regional'], ['==', ['get', 'management_type'], 'Régional']]
], 'icon-nature-regional',
['==', ['get', 'asset_type'], 'naturetree'], 'icon-nature',
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain', ['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain', ['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump', ['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump',

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-dead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#9ca3af"/>
<stop offset="100%" stop-color="#4b5563"/>
</linearGradient>
<linearGradient id="trunk-dead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#78716c"/>
<stop offset="100%" stop-color="#57534e"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-dead)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-dead)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-dead)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-dead)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk-dead)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#44403c"/>
</svg>

After

Width:  |  Height:  |  Size: 928 B

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-reg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8d6e63"/>
<stop offset="100%" stop-color="#5d4037"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-reg)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-reg)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-reg)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-reg)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#4e342e"/>
</svg>

After

Width:  |  Height:  |  Size: 913 B

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-rem" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8d6e63"/>
<stop offset="100%" stop-color="#5d4037"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-rem)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-rem)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-rem)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-rem)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#4e342e"/>
</svg>

After

Width:  |  Height:  |  Size: 913 B

View file

@ -549,7 +549,7 @@ function buildColumns() {
const symb = AssetSymbology.getAssetSymbology(row); const symb = AssetSymbology.getAssetSymbology(row);
const colorStyle = symb.color ? `color:${symb.color};` : ''; const colorStyle = symb.color ? `color:${symb.color};` : '';
iconPrefix = AssetSymbology.renderIconHtml(symb, { iconPrefix = AssetSymbology.renderIconHtml(symb, {
style: `${colorStyle} font-size: 0.95rem; vertical-align: -1px; margin-right: 5px;`, style: `${colorStyle} width: 20px; height: 20px; vertical-align: middle; margin-right: 6px;`,
imgClass: 'me-1' imgClass: 'me-1'
}); });
} }
@ -748,6 +748,14 @@ function applyInitialPreferences() {
window._restoredMapCenter = prefs.map_center; window._restoredMapCenter = prefs.map_center;
window._restoredMapZoom = prefs.map_zoom; window._restoredMapZoom = prefs.map_zoom;
} }
// Basemap preference
if (prefs.basemap) {
window._restoredBasemap = prefs.basemap;
try {
localStorage.setItem('loko_active_basemap', prefs.basemap);
} catch (e) {}
}
} else if (DEFAULT_THEMATIC_CODE && DEFAULT_THEMATIC_CODE in THEMATIC_CONFIG) { } else if (DEFAULT_THEMATIC_CODE && DEFAULT_THEMATIC_CODE in THEMATIC_CONFIG) {
// First visit: pre-select the user's default thematic // First visit: pre-select the user's default thematic
activeThematics = new Set([DEFAULT_THEMATIC_CODE]); activeThematics = new Set([DEFAULT_THEMATIC_CODE]);
@ -775,6 +783,9 @@ function savePreferences() {
).map(cb => cb.value); ).map(cb => cb.value);
const includeNoGeomCheckbox = document.getElementById('include-no-geom'); const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true; const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
const activeBasemap = (window.unifiedLayerSwitcher && window.unifiedLayerSwitcher.activeBasemapId)
|| localStorage.getItem('loko_active_basemap')
|| 'default';
const prefs = { const prefs = {
thematics: Array.from(activeThematics), thematics: Array.from(activeThematics),
categories: Array.from(activeCategories), categories: Array.from(activeCategories),
@ -787,6 +798,7 @@ function savePreferences() {
include_no_geom: includeNoGeom, include_no_geom: includeNoGeom,
map_center: (typeof map !== 'undefined' && map.getCenter) ? [map.getCenter().lng, map.getCenter().lat] : null, map_center: (typeof map !== 'undefined' && map.getCenter) ? [map.getCenter().lng, map.getCenter().lat] : null,
map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null, map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null,
basemap: activeBasemap,
}; };
fetch(SAVE_ASSETS_PREFS_URL, { fetch(SAVE_ASSETS_PREFS_URL, {
method: 'POST', method: 'POST',
@ -812,6 +824,12 @@ $(document).ready(function () {
$('#unified-table').bootstrapTable({ $('#unified-table').bootstrapTable({
columns: buildColumns(), columns: buildColumns(),
rowStyle: function (row) {
if (row && (row.is_dead || row.vitality === 'dead' || row.status === 'dead' || row.status === 'archived')) {
return { classes: 'text-muted opacity-75' };
}
return {};
},
height: calcHeight(), height: calcHeight(),
search: false, search: false,
pagination: true, pagination: true,
@ -1593,6 +1611,11 @@ function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
} }
} }
if (thematicCode === 'nature') { if (thematicCode === 'nature') {
if (Array.isArray(thematicFilters.management_type) && thematicFilters.management_type.length) {
payload.management_type = thematicFilters.management_type;
} else if (thematicFilters.management_type) {
payload.management_type = [thematicFilters.management_type];
}
if (thematicFilters.location_type) { if (thematicFilters.location_type) {
payload.location_type = thematicFilters.location_type; payload.location_type = thematicFilters.location_type;
} }
@ -2028,6 +2051,9 @@ function collectClickedAssets(clickEvent) {
detail_url: p.detail_url || null, detail_url: p.detail_url || null,
feature_id: id, feature_id: id,
extra_info: p.plots_count != null ? `${p.plots_count} plot(s)` : (p.bollard_type || ''), extra_info: p.plots_count != null ? `${p.plots_count} plot(s)` : (p.bollard_type || ''),
management_type: p.management_type || '',
managing_authority: p.managing_authority || '',
is_remarkable: p.is_remarkable || false,
}); });
}); });
@ -2729,6 +2755,13 @@ function loadTableData(features) {
model: p.model || '', model: p.model || '',
status: p.status || '', status: p.status || '',
detail_url: p.detail_url || null, detail_url: p.detail_url || null,
management_type: p.management_type || '',
managing_authority: p.managing_authority || '',
is_remarkable: p.is_remarkable || false,
scientific_name: p.scientific_name || '',
vernacular_name: p.vernacular_name || '',
is_dead: p.is_dead || false,
vitality: p.vitality || '',
lon: p.lon, lon: p.lon,
lat: p.lat, lat: p.lat,
length: Number.isFinite(length) ? length : 0, length: Number.isFinite(length) ? length : 0,
@ -2916,7 +2949,12 @@ maploader.addEventListener('mapLoaded', () => {
// Layer switcher — Urbis & Vlaanderen orthos (top-right, topmost) // Layer switcher — Urbis & Vlaanderen orthos (top-right, topmost)
if (typeof LayerSwitcherControl !== 'undefined') { if (typeof LayerSwitcherControl !== 'undefined') {
const initialBasemap = window._restoredBasemap || localStorage.getItem('loko_active_basemap') || 'default';
const layerSwitcher = new LayerSwitcherControl({ const layerSwitcher = new LayerSwitcherControl({
initialBasemapId: initialBasemap,
onBasemapChange: function (basemapId) {
schedulePreferencesSave();
},
layers: [{ layers: [{
id: 'basemap', id: 'basemap',
name: 'Fonds de plan & Orthophotos', name: 'Fonds de plan & Orthophotos',
@ -2924,7 +2962,10 @@ maploader.addEventListener('mapLoaded', () => {
beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'), beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'),
basemaps: window.MAP_BACKGROUNDS || [], basemaps: window.MAP_BACKGROUNDS || [],
}], }],
});// Reset view — fly back to initial Brussels Region view });
window.unifiedLayerSwitcher = layerSwitcher;
// Reset view — fly back to initial Brussels Region view
map.addControl({ map.addControl({
onAdd(m) { onAdd(m) {
this._map = m; this._map = m;

View file

@ -90,7 +90,10 @@ function loadUnifiedMapIcons(map) {
// ── File-based icons (full colour, non-SDF) ───────────────────────────── // ── File-based icons (full colour, non-SDF) ─────────────────────────────
// UNIFIED_ICON_URLS is set in the inline <script> of unified_index.html // UNIFIED_ICON_URLS is set in the inline <script> of unified_index.html
// using {% static %} so the URL is resolved server-side. // using {% static %} so the URL is resolved server-side.
const iconUrls = (typeof UNIFIED_ICON_URLS !== 'undefined') ? UNIFIED_ICON_URLS : {}; const injected = (typeof UNIFIED_ICON_URLS !== 'undefined') ? UNIFIED_ICON_URLS : {};
const iconUrls = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconUrls)
? Object.assign({}, AssetSymbology.getIconUrls(), injected)
: injected;
// trafficlights: traffic_light_icon.svg (56 px) // trafficlights: traffic_light_icon.svg (56 px)
if (iconUrls.trafficlights && !map.hasImage('icon-trafficlights')) { if (iconUrls.trafficlights && !map.hasImage('icon-trafficlights')) {
@ -140,6 +143,21 @@ function loadUnifiedMapIcons(map) {
.then(data => { if (!map.hasImage('icon-nature')) map.addImage('icon-nature', data); }) .then(data => { if (!map.hasImage('icon-nature')) map.addImage('icon-nature', data); })
.catch(e => console.warn('unified_map_layers: could not load nature icon', e)); .catch(e => console.warn('unified_map_layers: could not load nature icon', e));
} }
if (iconUrls.natureregional && !map.hasImage('icon-nature-regional')) {
loadNormalisedIcon(iconUrls.natureregional, 56)
.then(data => { if (!map.hasImage('icon-nature-regional')) map.addImage('icon-nature-regional', data); })
.catch(e => console.warn('unified_map_layers: could not load nature regional icon', e));
}
if (iconUrls.natureremarkable && !map.hasImage('icon-nature-remarkable')) {
loadNormalisedIcon(iconUrls.natureremarkable, 56)
.then(data => { if (!map.hasImage('icon-nature-remarkable')) map.addImage('icon-nature-remarkable', data); })
.catch(e => console.warn('unified_map_layers: could not load nature remarkable icon', e));
}
if (iconUrls.naturedead && !map.hasImage('icon-nature-dead')) {
loadNormalisedIcon(iconUrls.naturedead, 56)
.then(data => { if (!map.hasImage('icon-nature-dead')) map.addImage('icon-nature-dead', data); })
.catch(e => console.warn('unified_map_layers: could not load nature dead icon', e));
}
// structures: dedicated bridge/tunnel SVG (52 px) // structures: dedicated bridge/tunnel SVG (52 px)
if (iconUrls.structures && !map.hasImage('icon-structures')) { if (iconUrls.structures && !map.hasImage('icon-structures')) {
@ -211,7 +229,12 @@ function loadUnifiedMapIcons(map) {
// ── Bootstrap Icons glyph SDF icons (recoloured by icon-color) ────────── // ── Bootstrap Icons glyph SDF icons (recoloured by icon-color) ──────────
// codepoints and colors derived from AssetSymbology (single source of truth) // codepoints and colors derived from AssetSymbology (single source of truth)
const FILE_ICON_NAMES = new Set(Object.keys(iconUrls).map(k => 'icon-' + k)); const FILE_ICON_NAMES = new Set([
...Object.keys(iconUrls).map(k => 'icon-' + k),
'icon-nature-regional',
'icon-nature-remarkable',
'icon-nature-dead',
]);
const GLYPH_ICONS = (typeof AssetSymbology !== 'undefined') const GLYPH_ICONS = (typeof AssetSymbology !== 'undefined')
? AssetSymbology.getGlyphIcons() ? AssetSymbology.getGlyphIcons()

View file

@ -480,7 +480,7 @@
text-transform: uppercase !important; text-transform: uppercase !important;
letter-spacing: 0.04em !important; letter-spacing: 0.04em !important;
border-bottom: 1.5px solid #e2e8f0 !important; border-bottom: 1.5px solid #e2e8f0 !important;
padding: 0.65rem 0.75rem !important; padding: 0.45rem 0.65rem !important;
vertical-align: middle !important; vertical-align: middle !important;
white-space: nowrap !important; white-space: nowrap !important;
} }
@ -489,16 +489,28 @@
padding: 0 !important; padding: 0 !important;
font-size: 0.74rem !important; font-size: 0.74rem !important;
font-weight: 600 !important; font-weight: 600 !important;
line-height: 1.4 !important; line-height: 1.3 !important;
} }
/* Rows & Cells */ /* Rows & Cells */
.bootstrap-table.bootstrap5 #unified-table td { .bootstrap-table.bootstrap5 #unified-table td {
padding: 0.65rem 0.75rem !important; padding: 0.32rem 0.65rem !important;
vertical-align: middle !important; vertical-align: middle !important;
font-size: 0.83rem !important; font-size: 0.82rem !important;
color: #334155; color: #334155;
border-bottom: 1px solid #f1f5f9 !important; border-bottom: 1px solid #f1f5f9 !important;
line-height: 1.3 !important;
}
.asset-symbology-icon-img {
width: 20px !important;
height: 20px !important;
max-width: 20px !important;
max-height: 20px !important;
object-fit: contain !important;
vertical-align: middle !important;
display: inline-block !important;
margin-right: 6px !important;
} }
.bootstrap-table.bootstrap5 #unified-table tbody tr { .bootstrap-table.bootstrap5 #unified-table tbody tr {
@ -1303,7 +1315,7 @@
{% block script %} {% block script %}
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script> <script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script> <script src="{% static 'common/layer-switcher-control.js' %}?v=20261009_basemap"></script>
<script> <script>
window.TERRITORY_MASK_URL = "{% url 'common:get_territory_mask_geojson' %}"; window.TERRITORY_MASK_URL = "{% url 'common:get_territory_mask_geojson' %}";
const preventive_draft_id = "{{ preventive_draft_id|default:'' }}"; const preventive_draft_id = "{{ preventive_draft_id|default:'' }}";
@ -1341,7 +1353,10 @@
trafficlightelectricalcabinet: "{% static 'assets/electrical-panel.png' %}", trafficlightelectricalcabinet: "{% static 'assets/electrical-panel.png' %}",
publiclighting: "{% static 'assets/public_lamp_post_icon.svg' %}?v=20260521c", publiclighting: "{% static 'assets/public_lamp_post_icon.svg' %}?v=20260521c",
publiclightingelectricalcabinet: "{% static 'assets/electrical-panel.png' %}", publiclightingelectricalcabinet: "{% static 'assets/electrical-panel.png' %}",
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a", nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20261009_round",
natureregional: "{% static 'assets/nature_tree_icon_regional.svg' %}?v=20261009_round",
natureremarkable: "{% static 'assets/nature_tree_icon_remarkable.svg' %}?v=20261009_round",
naturedead: "{% static 'assets/nature_tree_icon_dead.svg' %}?v=20261009_round",
structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b", structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b",
roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a", roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a",
speedbump: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920", speedbump: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
@ -1358,7 +1373,7 @@
<script src="{% static 'common/base_script.js' %}"></script> <script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script> <script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script> <script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
<script src="{% static 'assets/asset_symbology.js' %}?v=20260920_grid_options"></script> <script src="{% static 'assets/asset_symbology.js' %}?v=20261009_round_trees_v2"></script>
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260920_grid_options"></script> <script src="{% static 'assets/unified_map_layers.js' %}?v=20261009_round_trees_v2"></script>
<script src="{% static 'assets/unified_index.js' %}?v=20260920_grid_options"></script> <script src="{% static 'assets/unified_index.js' %}?v=20261009_round_trees_v2"></script>
{% endblock script %} {% endblock script %}

View file

@ -5441,6 +5441,97 @@ class UserAccessibleMunicipalitiesTest(TestCase):
# When only 1 municipality is accessible, the filter should NOT be shown # When only 1 municipality is accessible, the filter should NOT be shown
self.assertIsNone(muni_filter) self.assertIsNone(muni_filter)
def test_nature_assets_geojson_restriction_and_filter(self):
from assets.models import NatureLocation, NatureTree
from common.models import Thematic, UserThematics
from django.urls import reverse
import json
thematic_nature, _ = Thematic.objects.get_or_create(code="nature", defaults={"name_fr": "Nature"})
UserThematics.objects.get_or_create(user_config=self.ext_config, thematic=thematic_nature, defaults={"can_view_assets": True})
loc_a = NatureLocation.objects.create(code="LOC-A", name_fr="Rue A", municipality=self.muni_a)
loc_c = NatureLocation.objects.create(code="LOC-C", name_fr="Rue C", municipality=self.muni_c)
tree_muni = NatureTree.objects.create(code="TR-A-MUNI", name_fr="Arbre Communal", location=loc_a, management_type="municipal")
tree_reg = NatureTree.objects.create(code="TR-A-REG", name_fr="Arbre Régional", location=loc_a, management_type="regional")
tree_c = NatureTree.objects.create(code="TR-C", name_fr="Arbre Inaccessible", location=loc_c, management_type="municipal")
self.client.force_login(self.user_ext)
# 1. Unfiltered request
resp = self.client.post(
reverse('assets:nature_assets_geojson') + '?unified=1',
data=json.dumps({}),
content_type='application/json'
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
ids = [f['id'] for f in data.get('features', [])]
self.assertIn(tree_muni.id, ids)
self.assertIn(tree_reg.id, ids)
self.assertNotIn(tree_c.id, ids)
# 2. Filter by municipal only
resp_muni = self.client.post(
reverse('assets:nature_assets_geojson') + '?unified=1',
data=json.dumps({'management_type': ['municipal']}),
content_type='application/json'
)
data_muni = resp_muni.json()
muni_ids = [f['id'] for f in data_muni.get('features', [])]
self.assertIn(tree_muni.id, muni_ids)
self.assertNotIn(tree_reg.id, muni_ids)
self.assertNotIn(tree_c.id, muni_ids)
# 3. Filter by regional only
resp_reg = self.client.post(
reverse('assets:nature_assets_geojson') + '?unified=1',
data=json.dumps({'management_type': ['regional']}),
content_type='application/json'
)
data_reg = resp_reg.json()
reg_ids = [f['id'] for f in data_reg.get('features', [])]
self.assertIn(tree_reg.id, reg_ids)
self.assertNotIn(tree_muni.id, reg_ids)
def test_nature_conditional_filter_config_dynamic_gestionnaire(self):
from assets.views.core import build_conditional_filter_config
from assets.models import NatureLocation, NatureTree
from common.models import Thematic, UserConfig, UserContractAccess
from contracts.models import Contract
thematic_nature, _ = Thematic.objects.get_or_create(code="nature", defaults={"name_fr": "Nature"})
# Single Walloon municipality user
self.muni_a.niscode = '25018' # Court-Saint-Étienne (Wallonia)
self.muni_a.name_fr = 'Court-Saint-Étienne'
self.muni_a.save()
user_cse = User.objects.create_user(username="test_cse_user", password="pwd")
cfg_cse = UserConfig.objects.create(user=user_cse, is_intern=False, limit_assets_to_contracts=True)
ctr_cse = Contract.objects.create(contract_number="CTR-CSE", company=self.company, start_date="2026-01-01", end_date="2026-12-31")
ctr_cse.municipalities.add(self.muni_a)
UserContractAccess.objects.create(user_config=cfg_cse, contract=ctr_cse, can_view_assets=True)
loc = NatureLocation.objects.create(code="LOC-CSE", name_fr="Rue CSE", municipality=self.muni_a)
NatureTree.objects.create(code="TR-CSE-M", location=loc, management_type="municipal")
NatureTree.objects.create(code="TR-CSE-R", location=loc, management_type="regional")
cfg = build_conditional_filter_config([thematic_nature], user=user_cse)
nat_filters = cfg.get('nature', [])
mgmt_filter = next((f for f in nat_filters if f['key'] == 'management_type'), None)
self.assertIsNotNone(mgmt_filter)
labels = [opt['label'] for opt in mgmt_filter['options']]
values = [opt['value'] for opt in mgmt_filter['options']]
self.assertIn('municipal', values)
self.assertIn('regional', values)
self.assertTrue(any('Court-Saint-Étienne' in lbl for lbl in labels))
self.assertTrue(any('Région wallonne' in lbl for lbl in labels))
self.assertFalse(any('Boitsfort' in lbl for lbl in labels))
self.assertFalse(any('Bruxelles Mobilité' in lbl for lbl in labels))
class NatureLocationDetailViewTest(TestCase): class NatureLocationDetailViewTest(TestCase):
def setUp(self): def setUp(self):

View file

@ -681,7 +681,7 @@ def build_conditional_filter_config(accessible_thematics, user=None):
# Roads / public lighting / sign / artworks: municipality on locations (only if more than 1 option) # Roads / public lighting / sign / artworks: municipality on locations (only if more than 1 option)
if len(municipality_options) > 1: if len(municipality_options) > 1:
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']: for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks', 'nature']:
if thematic_code in accessible_codes: if thematic_code in accessible_codes:
config.setdefault(thematic_code, []).append({ config.setdefault(thematic_code, []).append({
'key': 'municipality', 'key': 'municipality',
@ -752,7 +752,114 @@ def build_conditional_filter_config(accessible_thematics, user=None):
# Nature # Nature
if 'nature' in accessible_codes: if 'nature' in accessible_codes:
config['nature'] = [ from assets.models import NatureTree
def _get_muni_region(m):
if not m:
return None
code = str(getattr(m, 'niscode', '') or '').strip()
if code.startswith('21'):
return 'BRU'
elif code[:2] in ('25', '51', '52', '53', '55', '56', '57', '61', '62', '63', '81', '82', '83', '84', '85', '91', '92', '93') or (code and code[0] in ('5', '6', '8', '9')):
return 'WAL'
elif (code and code[0] in ('1', '3', '4', '7')) or code[:2] in ('23', '24'):
return 'VLG'
name = (getattr(m, 'name_fr', '') or '').lower()
if any(b in name for b in ('bruxelles', 'watermael', 'boitsfort', 'berchem', 'ixelles', 'etterbeek', 'uccle', 'woluwe', 'schaerbeek', 'anderlecht', 'forest', 'jette', 'ganshoren', 'evere', 'saint-gilles', 'saint-josse', 'auderghem', 'molenbeek', 'koekelberg')):
return 'BRU'
return None
regional_labels = {
'BRU': _('Région (Bruxelles Mobilité)'),
'WAL': _('Région (Région wallonne)'),
'VLG': _('Région (Région flamande)'),
}
user_cfg = getattr(user, 'config', None)
user_is_admin = getattr(user, 'is_superuser', False) or (user_cfg and (user_cfg.has_role('admin') or user_cfg.has_role('top_manager')))
user_is_ext = (user_cfg is None) or (not user_cfg.is_intern)
user_must_limit = user_is_ext or (user_cfg and user_cfg.limit_assets_to_contracts)
visible_trees_qs = NatureTree.objects.exclude(status='removed')
if not user_is_admin and user_must_limit:
visible_trees_qs = visible_trees_qs.filter(location__municipality__in=municipalities)
tree_stats = list(visible_trees_qs.values('management_type', 'location__municipality_id').distinct())
has_trees = len(tree_stats) > 0
muni_list = list(municipalities)
management_type_options = []
if len(muni_list) == 1:
single_muni = muni_list[0]
reg = _get_muni_region(single_muni) or 'BRU'
reg_label = regional_labels.get(reg, _('Région'))
has_municipal = any(t['management_type'] == 'municipal' for t in tree_stats) if has_trees else True
has_regional = any(t['management_type'] == 'regional' for t in tree_stats) if has_trees else True
if has_municipal:
management_type_options.append({
'value': 'municipal',
'label': _('Commune (%(name)s)') % {'name': single_muni.name}
})
if has_regional:
management_type_options.append({
'value': 'regional',
'label': reg_label
})
elif len(muni_list) > 1:
muni_map = {m.id: m for m in muni_list}
# Municipal options for each municipality with municipal trees
for m in muni_list:
has_m_trees = any(
t['management_type'] == 'municipal' and t['location__municipality_id'] == m.id
for t in tree_stats
) if has_trees else True
if has_m_trees:
management_type_options.append({
'value': f'municipal_{m.id}',
'label': _('Commune (%(name)s)') % {'name': m.name}
})
# Regional options grouped by region
regions_with_regional_trees = set()
for t in tree_stats:
if t['management_type'] == 'regional':
mid = t['location__municipality_id']
m_obj = muni_map.get(mid)
if m_obj:
reg = _get_muni_region(m_obj)
if reg:
regions_with_regional_trees.add(reg)
if not has_trees:
for m in muni_list:
reg = _get_muni_region(m)
if reg:
regions_with_regional_trees.add(reg)
for reg_code, reg_val in [('BRU', 'regional_bruxelles'), ('WAL', 'regional_wallonie'), ('VLG', 'regional_flandre')]:
if reg_code in regions_with_regional_trees:
management_type_options.append({
'value': reg_val,
'label': regional_labels[reg_code]
})
else:
management_type_options = [
{'value': 'municipal', 'label': _('Communal')},
{'value': 'regional', 'label': _('Régional')},
]
nature_filters = []
if management_type_options:
nature_filters.append({
'key': 'management_type',
'label': _('Gestionnaire'),
'type': 'multi',
'options': management_type_options,
})
nature_filters.extend([
{ {
'key': 'location_type', 'key': 'location_type',
'label': _('Type de localisation'), 'label': _('Type de localisation'),
@ -768,7 +875,8 @@ def build_conditional_filter_config(accessible_thematics, user=None):
'type': 'checkbox', 'type': 'checkbox',
'value': '1', 'value': '1',
}, },
] ])
config['nature'] = nature_filters
# Water # Water
if 'water' in accessible_codes: if 'water' in accessible_codes:

View file

@ -72,9 +72,29 @@ def nature_index(request):
category_choices = [(m.id, m.get_name()) for m in categories] category_choices = [(m.id, m.get_name()) for m in categories]
selected_management_types = request.GET.getlist('management_type') selected_management_types = request.GET.getlist('management_type')
from assets.permissions import get_user_accessible_municipalities
_munis = list(get_user_accessible_municipalities(request.user))
if len(_munis) == 1:
_sm = _munis[0]
_code = str(getattr(_sm, 'niscode', '') or '').strip()
_is_wal = _code[:2] in ('25', '51', '52', '53', '55', '56', '57', '61', '62', '63', '81', '82', '83', '84', '85', '91', '92', '93') or (_code and _code[0] in ('5', '6', '8', '9')) or 'court-saint' in (_sm.name_fr or '').lower()
_is_vlg = (_code and _code[0] in ('1', '3', '4', '7')) or _code[:2] in ('23', '24')
if _is_wal:
_reg_lbl = _("Régional (Région wallonne)")
elif _is_vlg:
_reg_lbl = _("Régional (Région flamande)")
else:
_reg_lbl = _("Régional (Bruxelles Mobilité)")
management_type_choices = [
('municipal', _("Communal (%(name)s)") % {'name': _sm.name}),
('regional', _reg_lbl),
('community', _("Intercommunal / Communautaire")),
('private', _("Privé / Conventionné")),
]
else:
management_type_choices = [ management_type_choices = [
('municipal', _("Communal (Commune)")), ('municipal', _("Communal (Commune)")),
('regional', _("Régional (Bruxelles Mobilité)")), ('regional', _("Régional (Région / Bruxelles Mobilité)")),
('community', _("Intercommunal / Communautaire")), ('community', _("Intercommunal / Communautaire")),
('private', _("Privé / Conventionné")), ('private', _("Privé / Conventionné")),
] ]
@ -462,10 +482,30 @@ def nature_locations_geojson(request):
filter_geom = None filter_geom = None
from assets.permissions import get_user_accessible_municipalities
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
municipalities = ensure_list(data.get('municipality')) if request.method == "POST" else request.GET.getlist('municipality')
include_naturelocation = location_type in ('', 'naturelocation') include_naturelocation = location_type in ('', 'naturelocation')
nature_locations = NatureLocation.objects.all() if include_naturelocation else NatureLocation.objects.none() nature_locations = NatureLocation.objects.all() if include_naturelocation else NatureLocation.objects.none()
if not is_admin and must_limit:
accessible_munis = get_user_accessible_municipalities(user)
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
if municipalities:
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
nature_locations = nature_locations.filter(municipality_id__in=valid_munis)
else:
nature_locations = nature_locations.filter(municipality_id__in=accessible_muni_ids)
elif municipalities:
nature_locations = nature_locations.filter(municipality_id__in=municipalities)
if highway: if highway:
nature_locations = nature_locations.filter(is_highway=highway) nature_locations = nature_locations.filter(is_highway=highway)
@ -681,6 +721,8 @@ def nature_assets_geojson(request):
"is_remarkable": obj.get('is_remarkable', False), "is_remarkable": obj.get('is_remarkable', False),
"scientific_name": obj.get('scientific_name') or '', "scientific_name": obj.get('scientific_name') or '',
"vernacular_name": obj.get('vernacular_name') or '', "vernacular_name": obj.get('vernacular_name') or '',
"is_dead": bool(obj.get('is_dead') or obj.get('vitality') == 'dead' or obj.get('status') in ('dead', 'archived')),
"vitality": obj.get('vitality') or '',
} }
} }
@ -707,6 +749,35 @@ def nature_assets_geojson(request):
NatureBench.objects.all() NatureBench.objects.all()
) )
from assets.permissions import get_user_accessible_municipalities
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
municipalities = ensure_list(data.get('municipality')) if request.method == "POST" else request.GET.getlist('municipality')
if not is_admin and must_limit:
accessible_munis = get_user_accessible_municipalities(user)
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
if municipalities:
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=valid_munis)
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=valid_munis)
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=valid_munis)
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=valid_munis)
else:
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=accessible_muni_ids)
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=accessible_muni_ids)
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=accessible_muni_ids)
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=accessible_muni_ids)
elif municipalities:
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=municipalities)
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=municipalities)
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=municipalities)
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=municipalities)
include_naturelocation = location_type in ('', 'naturelocation') include_naturelocation = location_type in ('', 'naturelocation')
if not include_naturelocation: if not include_naturelocation:
@ -715,7 +786,6 @@ def nature_assets_geojson(request):
natureshrub_qs = natureshrub_qs.none() natureshrub_qs = natureshrub_qs.none()
naturebench_qs = naturebench_qs.none() naturebench_qs = naturebench_qs.none()
if sections_ids: if sections_ids:
naturegreensurface_qs = naturegreensurface_qs.filter(location__id__in=sections_ids) naturegreensurface_qs = naturegreensurface_qs.filter(location__id__in=sections_ids)
naturetree_qs = naturetree_qs.filter(location__id__in=sections_ids) naturetree_qs = naturetree_qs.filter(location__id__in=sections_ids)
@ -734,7 +804,43 @@ def nature_assets_geojson(request):
naturebench_qs = naturebench_qs.filter(model__id__in=models) naturebench_qs = naturebench_qs.filter(model__id__in=models)
if management_types: if management_types:
naturetree_qs = naturetree_qs.filter(management_type__in=management_types) q_mgmt = Q()
for val in management_types:
val_str = str(val).strip()
if val_str == 'municipal':
q_mgmt |= Q(management_type='municipal')
elif val_str == 'regional':
q_mgmt |= Q(management_type='regional')
elif val_str == 'regional_bruxelles':
q_mgmt |= Q(management_type='regional') & (
Q(location__municipality__niscode__startswith='21') |
Q(location__municipality__name_fr__icontains='Bruxelles') |
Q(location__municipality__name_fr__icontains='Watermael') |
Q(location__municipality__name_fr__icontains='Berchem')
)
elif val_str == 'regional_wallonie':
q_mgmt |= Q(management_type='regional') & (
Q(location__municipality__niscode__startswith=('25', '5', '6', '8', '9')) |
Q(location__municipality__name_fr__icontains='Court-Saint')
)
elif val_str == 'regional_flandre':
q_mgmt |= Q(management_type='regional') & (
Q(location__municipality__niscode__startswith=('1', '3', '4', '7', '23', '24'))
)
elif val_str.startswith('municipal_'):
m_id = val_str.split('_', 1)[1]
if m_id.isdigit():
q_mgmt |= Q(management_type='municipal', location__municipality_id=int(m_id))
elif val_str.startswith('authority:'):
auth_name = val_str.split(':', 1)[1]
q_mgmt |= Q(managing_authority__iexact=auth_name)
else:
q_mgmt |= Q(managing_authority__icontains=val_str) | Q(management_type=val_str)
naturetree_qs = naturetree_qs.filter(q_mgmt)
naturegreensurface_qs = naturegreensurface_qs.none()
natureshrub_qs = natureshrub_qs.none()
naturebench_qs = naturebench_qs.none()
if categories: if categories:
naturegreensurface_qs = naturegreensurface_qs.filter(category__id__in=categories) naturegreensurface_qs = naturegreensurface_qs.filter(category__id__in=categories)
naturetree_qs = naturetree_qs.filter(category__id__in=categories) naturetree_qs = naturetree_qs.filter(category__id__in=categories)
@ -812,7 +918,8 @@ def nature_assets_geojson(request):
'category_name', 'model_name', 'color', 'geojson', 'category_name', 'model_name', 'color', 'geojson',
'category_id', 'model_id', 'location_lon', 'location_lat', 'category_id', 'model_id', 'location_lon', 'location_lat',
'managing_authority', 'management_type', 'is_remarkable', 'managing_authority', 'management_type', 'is_remarkable',
'scientific_name', 'vernacular_name' 'scientific_name', 'vernacular_name',
'is_dead', 'vitality'
) )
.order_by('code') .order_by('code')
)[:MAX_ASSET_FEATURES + 1] )[:MAX_ASSET_FEATURES + 1]
@ -884,12 +991,24 @@ def nature_locations_autocomplete(request):
if len(raw_query) < 2: if len(raw_query) < 2:
return JsonResponse([], safe=False) return JsonResponse([], safe=False)
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
# Normalise la requête (équivalent de unaccent+lower côté Python) # Normalise la requête (équivalent de unaccent+lower côté Python)
qn = strip_accents_lower(raw_query) qn = strip_accents_lower(raw_query)
# Prépare des colonnes "recherchables" unaccentées + minuscules # Prépare des colonnes "recherchables" unaccentées + minuscules
qs = NatureLocation.objects.all()
if not is_admin and must_limit:
from assets.permissions import get_user_accessible_municipalities
accessible_munis = get_user_accessible_municipalities(user)
qs = qs.filter(municipality__in=accessible_munis)
qs = ( qs = (
NatureLocation.objects qs
.annotate( .annotate(
code_u=Lower(Unaccent('code')), code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')), name_fr_u=Lower(Unaccent('name_fr')),
@ -954,13 +1073,25 @@ def nature_assets_autocomplete(request):
location_filter = {'location_id': location_id} location_filter = {'location_id': location_id}
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
def base_qs(model, extra_filter=None): def base_qs(model, extra_filter=None):
ann = { ann = {
'code_u': Lower(Unaccent('code')), 'code_u': Lower(Unaccent('code')),
'name_fr_u': Lower(Unaccent('name_fr')), 'name_fr_u': Lower(Unaccent('name_fr')),
'name_nl_u': Lower(Unaccent('name_nl')), 'name_nl_u': Lower(Unaccent('name_nl')),
} }
q = model.objects.annotate(**ann) q = model.objects.all()
if not is_admin and must_limit:
from assets.permissions import get_user_accessible_municipalities
accessible_munis = get_user_accessible_municipalities(user)
q = q.filter(location__municipality__in=accessible_munis)
q = q.annotate(**ann)
# Si un terme de recherche est fourni, filtrer dessus # Si un terme de recherche est fourni, filtrer dessus
if has_search_term: if has_search_term:

View file

@ -553,3 +553,8 @@ msgstr "Zoom in"
#: common/templates/common/components/document_viewer.html #: common/templates/common/components/document_viewer.html
msgid "Réinitialiser" msgid "Réinitialiser"
msgstr "Reset" msgstr "Reset"
#: common/views.py:683
msgid "Format de fond de plan invalide"
msgstr "Invalid basemap format"

View file

@ -539,3 +539,8 @@ msgstr "Zoomer"
#: common/templates/common/components/document_viewer.html #: common/templates/common/components/document_viewer.html
msgid "Réinitialiser" msgid "Réinitialiser"
msgstr "Réinitialiser" msgstr "Réinitialiser"
#: common/views.py:683
msgid "Format de fond de plan invalide"
msgstr "Format de fond de plan invalide"

View file

@ -539,3 +539,8 @@ msgstr "Inzoomen"
#: common/templates/common/components/document_viewer.html #: common/templates/common/components/document_viewer.html
msgid "Réinitialiser" msgid "Réinitialiser"
msgstr "Herstellen" msgstr "Herstellen"
#: common/views.py:683
msgid "Format de fond de plan invalide"
msgstr "Ongeldig basiskaartformaat"

View file

@ -381,7 +381,8 @@
// Extract or build basemaps list // Extract or build basemaps list
this.basemaps = this._resolveBasemaps(); this.basemaps = this._resolveBasemaps();
this.activeBasemapId = localStorage.getItem('loko_active_basemap') || 'default'; this.activeBasemapId = options.initialBasemapId || localStorage.getItem('loko_active_basemap') || 'default';
this.onBasemapChange = typeof options.onBasemapChange === 'function' ? options.onBasemapChange : null;
this._injectStyles(); this._injectStyles();
this._handleOutsideClick = this._handleOutsideClick.bind(this); this._handleOutsideClick = this._handleOutsideClick.bind(this);
@ -442,13 +443,26 @@
this.popover = this._createPopover(); this.popover = this._createPopover();
map.getContainer().appendChild(this.popover); map.getContainer().appendChild(this.popover);
// Initialize active basemap on map load // Initialize active basemap on map load (checking map._loaded, isStyleLoaded, or loaded)
if (map.loaded()) { const isMapReady = Boolean(
map._loaded ||
(typeof map.isStyleLoaded === 'function' && map.isStyleLoaded()) ||
map.loaded()
);
if (isMapReady) {
this._applyInitialBasemap(); this._applyInitialBasemap();
} else { } else {
map.on('load', () => this._applyInitialBasemap()); map.once('load', () => this._applyInitialBasemap());
} }
// Re-apply if the style reloads dynamically
map.on('style.load', () => {
if (this.activeBasemapId && this.activeBasemapId !== 'default') {
this._applyInitialBasemap();
}
});
document.addEventListener('click', this._handleOutsideClick); document.addEventListener('click', this._handleOutsideClick);
return this.container; return this.container;
@ -660,8 +674,11 @@
} }
selectBasemap(id) { selectBasemap(id) {
const previousId = this.activeBasemapId;
this.activeBasemapId = id; this.activeBasemapId = id;
try {
localStorage.setItem('loko_active_basemap', id); localStorage.setItem('loko_active_basemap', id);
} catch (e) {}
// Update UI list items // Update UI list items
if (this.popover) { if (this.popover) {
@ -682,9 +699,20 @@
this._applyRasterBasemap(bgConfig); this._applyRasterBasemap(bgConfig);
} }
} }
if (this.onBasemapChange && previousId !== id) {
try {
this.onBasemapChange(id);
} catch (err) {
console.error('[LayerSwitcherControl] onBasemapChange error:', err);
}
}
} }
_applyInitialBasemap() { _applyInitialBasemap() {
if (!this.basemaps || this.basemaps.length === 0) {
this.basemaps = this._resolveBasemaps();
}
if (this.activeBasemapId && this.activeBasemapId !== 'default') { if (this.activeBasemapId && this.activeBasemapId !== 'default') {
const bgConfig = this.basemaps.find(b => b.id === this.activeBasemapId); const bgConfig = this.basemaps.find(b => b.id === this.activeBasemapId);
if (bgConfig) { if (bgConfig) {
@ -693,14 +721,16 @@
return; return;
} }
} }
// Check if any basemap has is_default // Check if any basemap has is_default only if activeBasemapId is not set
if (!this.activeBasemapId) {
const defaultBg = this.basemaps.find(b => b.is_default); const defaultBg = this.basemaps.find(b => b.is_default);
if (defaultBg) { if (defaultBg) {
this.selectBasemap(defaultBg.id); this.selectBasemap(defaultBg.id);
} else { return;
this._updateButtonState();
} }
} }
this._updateButtonState();
}
_removeCurrentRasterLayer() { _removeCurrentRasterLayer() {
if (!this.map) return; if (!this.map) return;

View file

@ -324,6 +324,35 @@ class AssetsIndexPreferencesTests(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn("error", response.json()) self.assertIn("error", response.json())
def test_save_assets_index_preferences_with_basemap(self):
self.client.force_login(self.superuser)
save_url = reverse("common:save_assets_index_preferences")
payload = {
"thematics": ["sign"],
"basemap": "urbis_ortho",
}
response = self.client.post(save_url, json.dumps(payload), content_type="application/json")
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json().get("success"))
from common.models import UserConfig
config = UserConfig.objects.get(user=self.superuser)
self.assertEqual(
config.assets_index_preferences.get("basemap"),
"urbis_ortho"
)
def test_save_assets_index_preferences_invalid_basemap_returns_400(self):
self.client.force_login(self.superuser)
save_url = reverse("common:save_assets_index_preferences")
payload = {
"basemap": 9999,
}
response = self.client.post(save_url, json.dumps(payload), content_type="application/json")
self.assertEqual(response.status_code, 400)
self.assertIn("error", response.json())
class UserConfigCopyTests(TestCase): class UserConfigCopyTests(TestCase):

View file

@ -663,6 +663,7 @@ def save_assets_index_preferences(request):
include_no_geom = data.get('include_no_geom', True) include_no_geom = data.get('include_no_geom', True)
map_center = data.get('map_center') map_center = data.get('map_center')
map_zoom = data.get('map_zoom') map_zoom = data.get('map_zoom')
basemap = data.get('basemap')
if not isinstance(thematics, list): if not isinstance(thematics, list):
return JsonResponse({'error': _('Format de thématiques invalide')}, status=400) return JsonResponse({'error': _('Format de thématiques invalide')}, status=400)
@ -680,13 +681,18 @@ def save_assets_index_preferences(request):
return JsonResponse({'error': _('Format de filtres statut invalide')}, status=400) return JsonResponse({'error': _('Format de filtres statut invalide')}, status=400)
if not isinstance(validation_status_filters, list): if not isinstance(validation_status_filters, list):
return JsonResponse({'error': _('Format de filtres statut de validation invalide')}, status=400) return JsonResponse({'error': _('Format de filtres statut de validation invalide')}, status=400)
if basemap is not None and not isinstance(basemap, str):
return JsonResponse({'error': _('Format de fond de plan invalide')}, status=400)
try: try:
user_config, _created = UserConfig.objects.get_or_create(user=request.user) user_config, _created = UserConfig.objects.get_or_create(user=request.user)
existing_prefs = user_config.assets_index_preferences or {} existing_prefs = user_config.assets_index_preferences or {}
existing_visible_columns = existing_prefs.get('visible_columns', []) if isinstance(existing_prefs, dict) else [] if not isinstance(existing_prefs, dict):
existing_prefs = {}
existing_visible_columns = existing_prefs.get('visible_columns', [])
if not isinstance(existing_visible_columns, list): if not isinstance(existing_visible_columns, list):
existing_visible_columns = [] existing_visible_columns = []
existing_basemap = existing_prefs.get('basemap', 'default')
user_config.assets_index_preferences = { user_config.assets_index_preferences = {
'thematics': thematics, 'thematics': thematics,
@ -700,6 +706,7 @@ def save_assets_index_preferences(request):
'include_no_geom': include_no_geom, 'include_no_geom': include_no_geom,
'map_center': map_center, 'map_center': map_center,
'map_zoom': map_zoom, 'map_zoom': map_zoom,
'basemap': basemap if basemap is not None else existing_basemap,
} }
user_config.save(update_fields=['assets_index_preferences']) user_config.save(update_fields=['assets_index_preferences'])
return JsonResponse({'success': True}) return JsonResponse({'success': True})