fix(assets): dynamic nature management_type filter based on visible assets and scope nature endpoints by user accessible municipalities

This commit is contained in:
kdeterme 2026-10-09 13:50:49 +02:00
parent 587f4abf8a
commit f271d65be2
4 changed files with 351 additions and 19 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

@ -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,16 +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', 'key': 'management_type',
'label': _('Gestionnaire'), 'label': _('Gestionnaire'),
'type': 'multi', 'type': 'multi',
'options': [ 'options': management_type_options,
{'value': 'municipal', 'label': _('Commune (Watermael-Boitsfort)')}, })
{'value': 'regional', 'label': _('Région (Bruxelles Mobilité)')}, nature_filters.extend([
],
},
{ {
'key': 'location_type', 'key': 'location_type',
'label': _('Type de localisation'), 'label': _('Type de localisation'),
@ -777,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,12 +72,32 @@ 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')
management_type_choices = [ from assets.permissions import get_user_accessible_municipalities
('municipal', _("Communal (Commune)")), _munis = list(get_user_accessible_municipalities(request.user))
('regional', _("Régional (Bruxelles Mobilité)")), if len(_munis) == 1:
('community', _("Intercommunal / Communautaire")), _sm = _munis[0]
('private', _("Privé / Conventionné")), _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 = list(NatureAssetModel.objects.all())
models.sort(key=lambda m: (m.get_name() or "").lower()) models.sort(key=lambda m: (m.get_name() or "").lower())
@ -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)
@ -707,6 +747,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 +784,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 +802,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)
@ -884,12 +988,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 +1070,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: