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/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 bb12877..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,16 +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': [ - {'value': 'municipal', 'label': _('Commune (Watermael-Boitsfort)')}, - {'value': 'regional', 'label': _('Région (Bruxelles Mobilité)')}, - ], - }, + 'options': management_type_options, + }) + nature_filters.extend([ { 'key': 'location_type', 'label': _('Type de localisation'), @@ -777,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..8d2f941 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) @@ -707,6 +747,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 +784,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 +802,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) @@ -884,12 +988,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 +1070,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: