diff --git a/loko/assets/migrations/0032_merge_20261009_1348.py b/loko/assets/migrations/0032_merge_20261009_1348.py
new file mode 100644
index 0000000..93b84ef
--- /dev/null
+++ b/loko/assets/migrations/0032_merge_20261009_1348.py
@@ -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 = [
+ ]
diff --git a/loko/assets/static/assets/asset_symbology.js b/loko/assets/static/assets/asset_symbology.js
index 949baa3..0ab91b9 100644
--- a/loko/assets/static/assets/asset_symbology.js
+++ b/loko/assets/static/assets/asset_symbology.js
@@ -261,6 +261,33 @@
glyphCodepoint: 'f30b',
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: {
thematic: 'nature',
color: '#2e7d32',
@@ -578,6 +605,25 @@
}
// 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') {
const isPlanter = (item.asset_sub_type === 'planter') ||
['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 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) {
- const imgClass = options.imgClass || 'asset-symbology-icon-img';
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 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 `
`;
+ return `
`;
}
const iconClass = options.iconClass || symbology.iconClass || 'bi-pin-map';
@@ -653,6 +704,9 @@
publiclighting: injected.publiclighting || THEMATIC_CONFIG.publiclighting.iconUrl,
publiclightingelectricalcabinet: injected.publiclightingelectricalcabinet || ASSET_TYPE_OVERRIDES.publiclightingelectricalcabinet.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,
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
@@ -773,6 +827,26 @@
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
], 'icon-natureplanter',
['==', ['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'], 'roaddrain'], 'icon-roaddrain',
['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump',
diff --git a/loko/assets/static/assets/nature_tree_icon_dead.svg b/loko/assets/static/assets/nature_tree_icon_dead.svg
new file mode 100644
index 0000000..cac81e3
--- /dev/null
+++ b/loko/assets/static/assets/nature_tree_icon_dead.svg
@@ -0,0 +1,20 @@
+
diff --git a/loko/assets/static/assets/nature_tree_icon_regional.svg b/loko/assets/static/assets/nature_tree_icon_regional.svg
new file mode 100644
index 0000000..a07855d
--- /dev/null
+++ b/loko/assets/static/assets/nature_tree_icon_regional.svg
@@ -0,0 +1,20 @@
+
diff --git a/loko/assets/static/assets/nature_tree_icon_remarkable.svg b/loko/assets/static/assets/nature_tree_icon_remarkable.svg
new file mode 100644
index 0000000..2453db7
--- /dev/null
+++ b/loko/assets/static/assets/nature_tree_icon_remarkable.svg
@@ -0,0 +1,20 @@
+
diff --git a/loko/assets/static/assets/unified_index.js b/loko/assets/static/assets/unified_index.js
index 4331729..465f66a 100644
--- a/loko/assets/static/assets/unified_index.js
+++ b/loko/assets/static/assets/unified_index.js
@@ -549,7 +549,7 @@ function buildColumns() {
const symb = AssetSymbology.getAssetSymbology(row);
const colorStyle = symb.color ? `color:${symb.color};` : '';
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'
});
}
@@ -748,6 +748,14 @@ function applyInitialPreferences() {
window._restoredMapCenter = prefs.map_center;
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) {
// First visit: pre-select the user's default thematic
activeThematics = new Set([DEFAULT_THEMATIC_CODE]);
@@ -775,6 +783,9 @@ function savePreferences() {
).map(cb => cb.value);
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
+ const activeBasemap = (window.unifiedLayerSwitcher && window.unifiedLayerSwitcher.activeBasemapId)
+ || localStorage.getItem('loko_active_basemap')
+ || 'default';
const prefs = {
thematics: Array.from(activeThematics),
categories: Array.from(activeCategories),
@@ -787,6 +798,7 @@ function savePreferences() {
include_no_geom: includeNoGeom,
map_center: (typeof map !== 'undefined' && map.getCenter) ? [map.getCenter().lng, map.getCenter().lat] : null,
map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null,
+ basemap: activeBasemap,
};
fetch(SAVE_ASSETS_PREFS_URL, {
method: 'POST',
@@ -812,6 +824,12 @@ $(document).ready(function () {
$('#unified-table').bootstrapTable({
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(),
search: false,
pagination: true,
@@ -1593,6 +1611,11 @@ function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
}
}
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) {
payload.location_type = thematicFilters.location_type;
}
@@ -2028,6 +2051,9 @@ function collectClickedAssets(clickEvent) {
detail_url: p.detail_url || null,
feature_id: id,
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 || '',
status: p.status || '',
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,
lat: p.lat,
length: Number.isFinite(length) ? length : 0,
@@ -2916,7 +2949,12 @@ maploader.addEventListener('mapLoaded', () => {
// Layer switcher — Urbis & Vlaanderen orthos (top-right, topmost)
if (typeof LayerSwitcherControl !== 'undefined') {
+ const initialBasemap = window._restoredBasemap || localStorage.getItem('loko_active_basemap') || 'default';
const layerSwitcher = new LayerSwitcherControl({
+ initialBasemapId: initialBasemap,
+ onBasemapChange: function (basemapId) {
+ schedulePreferencesSave();
+ },
layers: [{
id: 'basemap',
name: 'Fonds de plan & Orthophotos',
@@ -2924,7 +2962,10 @@ maploader.addEventListener('mapLoaded', () => {
beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'),
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({
onAdd(m) {
this._map = m;
diff --git a/loko/assets/static/assets/unified_map_layers.js b/loko/assets/static/assets/unified_map_layers.js
index 7fdcd79..1fab787 100644
--- a/loko/assets/static/assets/unified_map_layers.js
+++ b/loko/assets/static/assets/unified_map_layers.js
@@ -90,7 +90,10 @@ function loadUnifiedMapIcons(map) {
// ── File-based icons (full colour, non-SDF) ─────────────────────────────
// UNIFIED_ICON_URLS is set in the inline
-
+
-
-
-
+
+
+
{% endblock script %}
\ No newline at end of file
diff --git a/loko/assets/tests.py b/loko/assets/tests.py
index 23470bd..0fc930b 100644
--- a/loko/assets/tests.py
+++ b/loko/assets/tests.py
@@ -5441,6 +5441,97 @@ class UserAccessibleMunicipalitiesTest(TestCase):
# When only 1 municipality is accessible, the filter should NOT be shown
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):
def setUp(self):
diff --git a/loko/assets/views/core.py b/loko/assets/views/core.py
index 2afb4e5..ed73a82 100644
--- a/loko/assets/views/core.py
+++ b/loko/assets/views/core.py
@@ -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)
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:
config.setdefault(thematic_code, []).append({
'key': 'municipality',
@@ -752,7 +752,114 @@ def build_conditional_filter_config(accessible_thematics, user=None):
# Nature
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',
'label': _('Type de localisation'),
@@ -768,7 +875,8 @@ def build_conditional_filter_config(accessible_thematics, user=None):
'type': 'checkbox',
'value': '1',
},
- ]
+ ])
+ config['nature'] = nature_filters
# Water
if 'water' in accessible_codes:
diff --git a/loko/assets/views/nature.py b/loko/assets/views/nature.py
index 915095b..e8d1c4a 100644
--- a/loko/assets/views/nature.py
+++ b/loko/assets/views/nature.py
@@ -72,12 +72,32 @@ def nature_index(request):
category_choices = [(m.id, m.get_name()) for m in categories]
selected_management_types = request.GET.getlist('management_type')
- management_type_choices = [
- ('municipal', _("Communal (Commune)")),
- ('regional', _("Régional (Bruxelles Mobilité)")),
- ('community', _("Intercommunal / Communautaire")),
- ('private', _("Privé / Conventionné")),
- ]
+ 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 = [
+ ('municipal', _("Communal (Commune)")),
+ ('regional', _("Régional (Région / Bruxelles Mobilité)")),
+ ('community', _("Intercommunal / Communautaire")),
+ ('private', _("Privé / Conventionné")),
+ ]
models = list(NatureAssetModel.objects.all())
models.sort(key=lambda m: (m.get_name() or "").lower())
@@ -462,10 +482,30 @@ def nature_locations_geojson(request):
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')
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:
nature_locations = nature_locations.filter(is_highway=highway)
@@ -681,6 +721,8 @@ def nature_assets_geojson(request):
"is_remarkable": obj.get('is_remarkable', False),
"scientific_name": obj.get('scientific_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()
)
+ 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')
if not include_naturelocation:
@@ -715,7 +786,6 @@ def nature_assets_geojson(request):
natureshrub_qs = natureshrub_qs.none()
naturebench_qs = naturebench_qs.none()
-
if sections_ids:
naturegreensurface_qs = naturegreensurface_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)
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:
naturegreensurface_qs = naturegreensurface_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_id', 'model_id', 'location_lon', 'location_lat',
'managing_authority', 'management_type', 'is_remarkable',
- 'scientific_name', 'vernacular_name'
+ 'scientific_name', 'vernacular_name',
+ 'is_dead', 'vitality'
)
.order_by('code')
)[:MAX_ASSET_FEATURES + 1]
@@ -884,12 +991,24 @@ def nature_locations_autocomplete(request):
if len(raw_query) < 2:
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)
qn = strip_accents_lower(raw_query)
# 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 = (
- NatureLocation.objects
+ qs
.annotate(
code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')),
@@ -954,13 +1073,25 @@ def nature_assets_autocomplete(request):
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):
ann = {
'code_u': Lower(Unaccent('code')),
'name_fr_u': Lower(Unaccent('name_fr')),
'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
if has_search_term:
diff --git a/loko/common/locale/en/LC_MESSAGES/django.mo b/loko/common/locale/en/LC_MESSAGES/django.mo
index 7eb40fc..a64342f 100644
Binary files a/loko/common/locale/en/LC_MESSAGES/django.mo and b/loko/common/locale/en/LC_MESSAGES/django.mo differ
diff --git a/loko/common/locale/en/LC_MESSAGES/django.po b/loko/common/locale/en/LC_MESSAGES/django.po
index cdbbd5c..5c1f710 100644
--- a/loko/common/locale/en/LC_MESSAGES/django.po
+++ b/loko/common/locale/en/LC_MESSAGES/django.po
@@ -553,3 +553,8 @@ msgstr "Zoom in"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Reset"
+
+#: common/views.py:683
+msgid "Format de fond de plan invalide"
+msgstr "Invalid basemap format"
+
diff --git a/loko/common/locale/fr/LC_MESSAGES/django.mo b/loko/common/locale/fr/LC_MESSAGES/django.mo
index 8d040b2..94e12ae 100644
Binary files a/loko/common/locale/fr/LC_MESSAGES/django.mo and b/loko/common/locale/fr/LC_MESSAGES/django.mo differ
diff --git a/loko/common/locale/fr/LC_MESSAGES/django.po b/loko/common/locale/fr/LC_MESSAGES/django.po
index 0b12f73..b125da3 100644
--- a/loko/common/locale/fr/LC_MESSAGES/django.po
+++ b/loko/common/locale/fr/LC_MESSAGES/django.po
@@ -539,3 +539,8 @@ msgstr "Zoomer"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Réinitialiser"
+
+#: common/views.py:683
+msgid "Format de fond de plan invalide"
+msgstr "Format de fond de plan invalide"
+
diff --git a/loko/common/locale/nl/LC_MESSAGES/django.mo b/loko/common/locale/nl/LC_MESSAGES/django.mo
index e47b6d7..d8192e4 100644
Binary files a/loko/common/locale/nl/LC_MESSAGES/django.mo and b/loko/common/locale/nl/LC_MESSAGES/django.mo differ
diff --git a/loko/common/locale/nl/LC_MESSAGES/django.po b/loko/common/locale/nl/LC_MESSAGES/django.po
index dd7466b..bc848c5 100644
--- a/loko/common/locale/nl/LC_MESSAGES/django.po
+++ b/loko/common/locale/nl/LC_MESSAGES/django.po
@@ -539,3 +539,8 @@ msgstr "Inzoomen"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Herstellen"
+
+#: common/views.py:683
+msgid "Format de fond de plan invalide"
+msgstr "Ongeldig basiskaartformaat"
+
diff --git a/loko/common/static/common/layer-switcher-control.js b/loko/common/static/common/layer-switcher-control.js
index 5e37a1d..495b05f 100644
--- a/loko/common/static/common/layer-switcher-control.js
+++ b/loko/common/static/common/layer-switcher-control.js
@@ -381,7 +381,8 @@
// Extract or build basemaps list
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._handleOutsideClick = this._handleOutsideClick.bind(this);
@@ -442,13 +443,26 @@
this.popover = this._createPopover();
map.getContainer().appendChild(this.popover);
- // Initialize active basemap on map load
- if (map.loaded()) {
+ // Initialize active basemap on map load (checking map._loaded, isStyleLoaded, or loaded)
+ const isMapReady = Boolean(
+ map._loaded ||
+ (typeof map.isStyleLoaded === 'function' && map.isStyleLoaded()) ||
+ map.loaded()
+ );
+
+ if (isMapReady) {
this._applyInitialBasemap();
} 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);
return this.container;
@@ -660,8 +674,11 @@
}
selectBasemap(id) {
+ const previousId = this.activeBasemapId;
this.activeBasemapId = id;
- localStorage.setItem('loko_active_basemap', id);
+ try {
+ localStorage.setItem('loko_active_basemap', id);
+ } catch (e) {}
// Update UI list items
if (this.popover) {
@@ -682,9 +699,20 @@
this._applyRasterBasemap(bgConfig);
}
}
+
+ if (this.onBasemapChange && previousId !== id) {
+ try {
+ this.onBasemapChange(id);
+ } catch (err) {
+ console.error('[LayerSwitcherControl] onBasemapChange error:', err);
+ }
+ }
}
_applyInitialBasemap() {
+ if (!this.basemaps || this.basemaps.length === 0) {
+ this.basemaps = this._resolveBasemaps();
+ }
if (this.activeBasemapId && this.activeBasemapId !== 'default') {
const bgConfig = this.basemaps.find(b => b.id === this.activeBasemapId);
if (bgConfig) {
@@ -693,13 +721,15 @@
return;
}
}
- // Check if any basemap has is_default
- const defaultBg = this.basemaps.find(b => b.is_default);
- if (defaultBg) {
- this.selectBasemap(defaultBg.id);
- } else {
- this._updateButtonState();
+ // 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);
+ if (defaultBg) {
+ this.selectBasemap(defaultBg.id);
+ return;
+ }
}
+ this._updateButtonState();
}
_removeCurrentRasterLayer() {
diff --git a/loko/common/tests.py b/loko/common/tests.py
index f0ab794..48fbb8e 100644
--- a/loko/common/tests.py
+++ b/loko/common/tests.py
@@ -324,6 +324,35 @@ class AssetsIndexPreferencesTests(TestCase):
self.assertEqual(response.status_code, 400)
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):
diff --git a/loko/common/views.py b/loko/common/views.py
index 1683737..c5c7cec 100644
--- a/loko/common/views.py
+++ b/loko/common/views.py
@@ -663,6 +663,7 @@ def save_assets_index_preferences(request):
include_no_geom = data.get('include_no_geom', True)
map_center = data.get('map_center')
map_zoom = data.get('map_zoom')
+ basemap = data.get('basemap')
if not isinstance(thematics, list):
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)
if not isinstance(validation_status_filters, list):
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:
user_config, _created = UserConfig.objects.get_or_create(user=request.user)
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):
existing_visible_columns = []
+ existing_basemap = existing_prefs.get('basemap', 'default')
user_config.assets_index_preferences = {
'thematics': thematics,
@@ -700,6 +706,7 @@ def save_assets_index_preferences(request):
'include_no_geom': include_no_geom,
'map_center': map_center,
'map_zoom': map_zoom,
+ 'basemap': basemap if basemap is not None else existing_basemap,
}
user_config.save(update_fields=['assets_index_preferences'])
return JsonResponse({'success': True})