From 73738b675739341cf276965946627e26d61d16bc Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sat, 19 Sep 2026 18:33:39 +0200 Subject: [PATCH] feat: support multi-commune contracts, base asset categories, and filtered municipality access --- .../commands/load_base_asset_categories.py | 125 +++ loko/assets/permissions.py | 89 ++ loko/assets/tests.py | 260 ++++++ loko/assets/views/artworks.py | 8 +- loko/assets/views/core.py | 4 +- loko/assets/views/publiclighting.py | 3 +- loko/assets/views/roads.py | 46 +- loko/assets/views/sign.py | 3 +- loko/common/fixtures/asset_categories.json | 828 ++++++++++++++++++ loko/contracts/admin.py | 12 +- .../migrations/0010_contract_municipality.py | 20 + .../0011_contract_municipalities.py | 46 + loko/contracts/models.py | 12 + 13 files changed, 1438 insertions(+), 18 deletions(-) create mode 100644 loko/assets/management/commands/load_base_asset_categories.py create mode 100644 loko/common/fixtures/asset_categories.json create mode 100644 loko/contracts/migrations/0010_contract_municipality.py create mode 100644 loko/contracts/migrations/0011_contract_municipalities.py diff --git a/loko/assets/management/commands/load_base_asset_categories.py b/loko/assets/management/commands/load_base_asset_categories.py new file mode 100644 index 0000000..fbe0fa7 --- /dev/null +++ b/loko/assets/management/commands/load_base_asset_categories.py @@ -0,0 +1,125 @@ +""" +Management command to load baseline asset categories (from loko-bm) and +associate existing asset models and assets with their corresponding categories. +""" +import json +import os +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand +from common.models import Thematic +from assets.models import ( + AssetCategory, + CleanLitterBin, CleanLitterBinModel, + CleanGlassContainer, CleanGlassContainerModel, + RoadDrain, RoadDrainModel, + Roadway, RoadwayModel, + RoadSidewalk, RoadSidewalkModel, + RoadFurniture, RoadFurnitureModel, + StructureAssetModel, +) + + +class Command(BaseCommand): + help = "Charge les catégories d'assets de base (définies dans loko-bm) et lie les modèles existants." + + def add_arguments(self, parser): + parser.add_argument( + '--file', + type=str, + help="Chemin vers le fichier JSON des catégories (défaut: common/fixtures/asset_categories.json)", + ) + + def handle(self, *args, **options): + # 1. Localiser le fichier JSON + json_path = options.get('file') + candidates = [] + if not json_path: + base_dir = getattr(settings, 'BASE_DIR', None) + candidates = [ + os.path.join(base_dir, 'common', 'fixtures', 'asset_categories.json') if base_dir else None, + os.path.join(base_dir, 'loko', 'common', 'fixtures', 'asset_categories.json') if base_dir else None, + '/app/common/fixtures/asset_categories.json', + '/app/loko/common/fixtures/asset_categories.json', + os.path.join(os.path.dirname(__file__), '..', '..', '..', 'common', 'fixtures', 'asset_categories.json'), + ] + for c in candidates: + if c and os.path.exists(c): + json_path = os.path.abspath(c) + break + + if not json_path or not os.path.exists(json_path): + self.stderr.write(self.style.ERROR(f"Fichier asset_categories.json introuvable. Candidats testés: {candidates}")) + return + + self.stdout.write(f"Chargement des catégories depuis : {json_path}") + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + created_count = 0 + updated_count = 0 + + # Cache thématiques + thematics_by_code = {t.code: t for t in Thematic.objects.all()} + + # 2. Création / Mise à jour des catégories + for item in data: + code = item['code'] + name_fr = item['name_fr'] + name_nl = item.get('name_nl') or name_fr + thematic_code = item.get('thematic_code') + thematic = thematics_by_code.get(thematic_code) + + cat, created = AssetCategory.objects.update_or_create( + code=code, + defaults={ + 'name_fr': name_fr, + 'name_nl': name_nl, + 'thematic': thematic, + } + ) + if created: + created_count += 1 + else: + updated_count += 1 + + # Modèles Django autorisés (ContentType) + allowed_models = item.get('allowed_models', []) + if allowed_models: + cts = ContentType.objects.filter(model__in=allowed_models) + cat.allowed_models.set(cts) + + self.stdout.write(self.style.SUCCESS( + f"✅ Catégories d'assets : {created_count} créées, {updated_count} mises à jour (Total: {len(data)})." + )) + + # 3. Association automatique des modèles d'assets existants aux catégories correspondantes + self.link_models_to_categories() + + def link_models_to_categories(self): + links = [ + ('CleanLitterBinModel', CleanLitterBinModel, 'CL_BASKET'), + ('CleanLitterBin', CleanLitterBin, 'CL_BASKET'), + ('CleanGlassContainerModel', CleanGlassContainerModel, 'CL_BUBBLE'), + ('CleanGlassContainer', CleanGlassContainer, 'CL_BUBBLE'), + ('RoadDrainModel', RoadDrainModel, 'RD_DRAIN'), + ('RoadDrain', RoadDrain, 'RD_DRAIN'), + ('RoadwayModel', RoadwayModel, 'RD_ROADWAY'), + ('Roadway', Roadway, 'RD_ROADWAY'), + ('RoadSidewalkModel', RoadSidewalkModel, 'RD_SIDEWALK'), + ('RoadSidewalk', RoadSidewalk, 'RD_SIDEWALK'), + ('RoadFurnitureModel', RoadFurnitureModel, 'RD_STREET_FURNITURE'), + ('RoadFurniture', RoadFurniture, 'RD_STREET_FURNITURE'), + ('StructureAssetModel', StructureAssetModel, 'TU_CIVIL_ENGINEERING'), + ] + + for name, model_cls, cat_code in links: + cat = AssetCategory.objects.filter(code=cat_code).first() + if not cat: + continue + try: + updated = model_cls.objects.filter(category__isnull=True).update(category=cat) + if updated: + self.stdout.write(f" • {name} : {updated} enregistrement(s) lié(s) à la catégorie {cat.name_fr} ({cat_code}).") + except Exception as e: + self.stderr.write(f" ⚠️ Erreur liaison {name} -> {cat_code}: {e}") diff --git a/loko/assets/permissions.py b/loko/assets/permissions.py index e5074f2..ac644bc 100644 --- a/loko/assets/permissions.py +++ b/loko/assets/permissions.py @@ -796,3 +796,92 @@ def can_edit_exceptional_transport(user): and getattr(user_config, 'exceptional_transport_can_edit', False) ) + + +def get_user_accessible_municipalities(user): + """ + Retourne le queryset des communes (Municipality) auxquelles l'utilisateur a accès. + - Si superuser ou (utilisateur interne sans limitation aux contrats) : + retourne toutes les communes (Municipality.objects.all()). + - Si utilisateur externe ou avec limit_assets_to_contracts=True : + retourne uniquement les communes associées à ses contrats accessibles + (via Contract.municipalities ou localisations liées). + """ + from common.models import Municipality, UserConfig, UserContractAccess + from django.utils.translation import get_language + + if not user or not user.is_authenticated: + return Municipality.objects.none() + + if getattr(user, 'is_superuser', False): + lang = get_language() or 'fr' + order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' + return Municipality.objects.all().order_by(order_field) + + user_config = getattr(user, 'config', None) + if not user_config: + try: + user_config = UserConfig.objects.get(user=user) + except UserConfig.DoesNotExist: + return Municipality.objects.none() + + if user_config.has_role('admin') or user_config.has_role('top_manager'): + lang = get_language() or 'fr' + order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' + return Municipality.objects.all().order_by(order_field) + + is_external = not user_config.is_intern + must_limit = is_external or user_config.limit_assets_to_contracts + + if not must_limit: + lang = get_language() or 'fr' + order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' + return Municipality.objects.all().order_by(order_field) + + # Utilisateur restreint par contrats : + accessible_contract_ids = set( + UserContractAccess.objects.filter( + user_config=user_config, + can_view_assets=True + ).values_list('contract_id', flat=True) + ) + if user_config.default_contract_id: + accessible_contract_ids.add(user_config.default_contract_id) + + if not accessible_contract_ids: + return Municipality.objects.none() + + # Communes associées via ManyToMany Contract.municipalities + municipality_ids = set( + Municipality.objects.filter( + contracts__id__in=accessible_contract_ids + ).values_list('id', flat=True) + ) + + # Communes associées via les localisations liées aux contrats + try: + from assets.models import CleanLocationContract + clean_muni_ids = CleanLocationContract.objects.filter( + contract_id__in=accessible_contract_ids, + location__municipality__isnull=False + ).values_list('location__municipality_id', flat=True) + municipality_ids.update(clean_muni_ids) + except Exception: + pass + + try: + from assets.models import TrafficLightContract + tl_muni_ids = TrafficLightContract.objects.filter( + contract_id__in=accessible_contract_ids, + intersection__municipality__isnull=False + ).values_list('intersection__municipality_id', flat=True) + municipality_ids.update(tl_muni_ids) + except Exception: + pass + + if not municipality_ids: + return Municipality.objects.none() + + lang = get_language() or 'fr' + order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' + return Municipality.objects.filter(id__in=municipality_ids).order_by(order_field) diff --git a/loko/assets/tests.py b/loko/assets/tests.py index 0ef671f..5b7bd78 100644 --- a/loko/assets/tests.py +++ b/loko/assets/tests.py @@ -2313,6 +2313,185 @@ class TrafficLightCrossPlanImportTest(TestCase): if os.path.exists(tmp_path): os.remove(tmp_path) + def test_integrate_cross_plan_update_all(self): + import openpyxl + import tempfile + from django.contrib.gis.geos import Point as DjangoPoint + from assets.models import TrafficLightPole, TrafficLightPoleModel, TrafficLightLantern + + pole_model = TrafficLightPoleModel.objects.first() + + # 1. Existing pole B01 in DB (will be updated) + existing_pole = TrafficLightPole.objects.create( + intersection=self.intersection, + code="SWB01_B01", + name_fr="Ancien B01", + geom=DjangoPoint(149250.0, 170550.0, srid=3812), + lon=4.35, + lat=50.84, + model=pole_model, + status="active" + ) + # 2. Existing untouched pole B99 in DB (should remain active after update_all) + untouched_pole = TrafficLightPole.objects.create( + intersection=self.intersection, + code="SWB01_B99", + name_fr="Poteau Untouched", + model=pole_model, + status="active" + ) + + # 3. Create cross plan workbook containing B01 (with new coordinates) and B02 (new pole) + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "kruisjesplan" + ws.cell(row=1, column=1, value="Sleutel:") + ws.cell(row=1, column=2, value="SWB01") + ws.cell(row=5, column=1, value="Câble 01 SVAVB") + ws.cell(row=6, column=3, value="richting") + ws.cell(row=6, column=4, value="kringen") + ws.cell(row=7, column=5, value="B01") + ws.cell(row=7, column=6, value="B02") + ws.cell(row=8, column=1, value="com") + ws.cell(row=8, column=2, value="1") + ws.cell(row=8, column=3, value="A") + ws.cell(row=8, column=5, value="X") + ws.cell(row=8, column=6, value="X") + + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: + tmp_path = tmp.name + wb.save(tmp_path) + + try: + self.client.force_login(self.user) + from django.core.files.uploadedfile import SimpleUploadedFile + + with open(tmp_path, 'rb') as f: + uploaded_file = SimpleUploadedFile("update_plan.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + + url_import = reverse("assets:import_cross_plan", args=[self.intersection.id]) + response = self.client.post(url_import, {"cross_plan_file_upload": uploaded_file}) + self.assertEqual(response.status_code, 200) + + # Call integrate with global_action="update_all" + url_integrate = reverse("assets:integrate_cross_plan", args=[self.intersection.id]) + response_integrate = self.client.post(url_integrate, { + "cross_plan_filename": response.context["cross_plan_filename"], + "global_action": "update_all", + "name_pole_SWB01_B01": "B01 Mis à jour", + "x_3812_pole_SWB01_B01": "149260.0", + "y_3812_pole_SWB01_B01": "170560.0", + "lon_pole_SWB01_B01": "4.351", + "lat_pole_SWB01_B01": "50.841", + "name_pole_SWB01_B02": "Nouveau B02", + "x_3812_pole_SWB01_B02": "149300.0", + "y_3812_pole_SWB01_B02": "170600.0", + }) + self.assertEqual(response_integrate.status_code, 302) + + # Verify: + # - existing_pole should still have status="active", ID unchanged, name updated, geometry updated + existing_pole.refresh_from_db() + self.assertEqual(existing_pole.status, "active") + self.assertEqual(existing_pole.name_fr, "B01 Mis à jour") + self.assertEqual(existing_pole.geom.x, 149260.0) + self.assertEqual(existing_pole.geom.y, 170560.0) + + # - untouched_pole should still be active + untouched_pole.refresh_from_db() + self.assertEqual(untouched_pole.status, "active") + + # - new pole B02 should be created active + new_pole = TrafficLightPole.objects.filter(intersection=self.intersection, code="SWB01_B02", status="active").first() + self.assertIsNotNone(new_pole) + self.assertEqual(new_pole.name_fr, "Nouveau B02") + self.assertEqual(new_pole.geom.x, 149300.0) + + # - lantern on B01 and lantern on B02 should be active + l1 = TrafficLightLantern.objects.filter(pole=existing_pole, status="active").first() + self.assertIsNotNone(l1) + l2 = TrafficLightLantern.objects.filter(pole=new_pole, status="active").first() + self.assertIsNotNone(l2) + finally: + import os + if os.path.exists(tmp_path): + os.remove(tmp_path) + + def test_import_cross_plan_dxf_geometry_diff(self): + import openpyxl + import tempfile + from django.contrib.gis.geos import Point as DjangoPoint + from unittest.mock import patch + from assets.models import TrafficLightPole, TrafficLightPoleModel + + pole_model = TrafficLightPoleModel.objects.first() + + # Existing pole B01 with initial coordinates in DB + existing_pole = TrafficLightPole.objects.create( + intersection=self.intersection, + code="SWB01_B01", + geom=DjangoPoint(149200.0, 170500.0, srid=3812), + lon=4.350, + lat=50.840, + model=pole_model, + status="active" + ) + + # Cross plan workbook with pole B01 + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "kruisjesplan" + ws.cell(row=1, column=1, value="Sleutel:") + ws.cell(row=1, column=2, value="SWB01") + ws.cell(row=5, column=1, value="Câble 01 SVAVB") + ws.cell(row=6, column=3, value="richting") + ws.cell(row=6, column=4, value="kringen") + ws.cell(row=7, column=5, value="B01") + ws.cell(row=8, column=3, value="A") + ws.cell(row=8, column=5, value="X") + + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: + tmp_path = tmp.name + wb.save(tmp_path) + + # Mock DXF poles returned with a 2.5m shift (x + 2.0, y + 1.5 -> distance = 2.5m) + mock_dxf_poles = [{ + 'label': 'B01', + 'lon': 4.35003, + 'lat': 50.84002, + 'x_3812': 149202.0, + 'y_3812': 170501.5 + }] + + try: + self.client.force_login(self.user) + from django.core.files.uploadedfile import SimpleUploadedFile + + with open(tmp_path, 'rb') as f: + uploaded_file = SimpleUploadedFile("dxf_diff_plan.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + + with patch('assets.views.trafficlights.resolve_dxf_file_path', return_value='/dummy/path.dxf'), \ + patch('assets.views.trafficlights.parse_dxf_poles', return_value=mock_dxf_poles): + url_import = reverse("assets:import_cross_plan", args=[self.intersection.id]) + response = self.client.post(url_import, { + "cross_plan_file_upload": uploaded_file, + "selected_dxf": "test.dxf" + }) + + self.assertEqual(response.status_code, 200) + preview_poles = response.context["poles"] + b01 = next(p for p in preview_poles if p["code"] == "SWB01_B01") + self.assertTrue(b01["geom_modified"]) + self.assertEqual(b01["distance_diff"], 2.5) + self.assertEqual(b01["status"], "modified") + self.assertEqual(b01["db_x_3812"], 149200.0) + self.assertEqual(b01["new_x_3812"], 149202.0) + self.assertTrue(any("Position modifiée (DXF)" in d for d in b01["details_diff"])) + finally: + import os + if os.path.exists(tmp_path): + os.remove(tmp_path) + class AssetDefaultPositionTest(TestCase): def test_assign_default_position_hierarchy(self): @@ -4140,3 +4319,84 @@ class ExportAssetsTest(TestCase): + + +class UserAccessibleMunicipalitiesTest(TestCase): + def setUp(self): + from common.models import Municipality, UserConfig, UserContractAccess, Thematic, UserThematics + from contracts.models import Company, Contract + self.muni_a = Municipality.objects.create(name_fr="Commune A", name_nl="Gemeente A", fid="1001") + self.muni_b = Municipality.objects.create(name_fr="Commune B", name_nl="Gemeente B", fid="1002") + self.muni_c = Municipality.objects.create(name_fr="Commune C", name_nl="Gemeente C", fid="1003") + + self.superuser = User.objects.create_superuser(username="admin_super", password="pass123") + self.user_intern = User.objects.create_user(username="user_intern", password="pass123") + UserConfig.objects.create(user=self.user_intern, is_intern=True, limit_assets_to_contracts=False) + + self.user_ext = User.objects.create_user(username="user_ext", password="pass123") + self.ext_config = UserConfig.objects.create(user=self.user_ext, is_intern=False, limit_assets_to_contracts=True) + + self.company = Company.objects.create(name="Test Company") + self.contract = Contract.objects.create(contract_number="CTR-001", company=self.company, start_date="2026-01-01", end_date="2026-12-31") + self.contract.municipalities.add(self.muni_a, self.muni_b) + + UserContractAccess.objects.create( + user_config=self.ext_config, + contract=self.contract, + can_view_assets=True + ) + + thematic_roads, _ = Thematic.objects.get_or_create(code="roads", defaults={"name_fr": "Voiries"}) + UserThematics.objects.create(user_config=self.ext_config, thematic=thematic_roads, can_view_assets=True) + + def test_superuser_sees_all_municipalities(self): + from assets.permissions import get_user_accessible_municipalities + munis = list(get_user_accessible_municipalities(self.superuser)) + self.assertEqual(len(munis), 3) + + def test_intern_unrestricted_sees_all_municipalities(self): + from assets.permissions import get_user_accessible_municipalities + munis = list(get_user_accessible_municipalities(self.user_intern)) + self.assertEqual(len(munis), 3) + + def test_restricted_user_sees_only_contracted_municipalities(self): + from assets.permissions import get_user_accessible_municipalities + munis = list(get_user_accessible_municipalities(self.user_ext)) + muni_ids = {m.id for m in munis} + self.assertIn(self.muni_a.id, muni_ids) + self.assertIn(self.muni_b.id, muni_ids) + self.assertNotIn(self.muni_c.id, muni_ids) + + def test_restricted_user_without_contracts_sees_none(self): + from assets.permissions import get_user_accessible_municipalities + user_empty = User.objects.create_user(username="user_empty", password="pass123") + UserConfig.objects.create(user=user_empty, is_intern=False, limit_assets_to_contracts=True) + munis = list(get_user_accessible_municipalities(user_empty)) + self.assertEqual(len(munis), 0) + + def test_build_conditional_filter_config_restricted(self): + from assets.views.core import build_conditional_filter_config + thematic_roads = Thematic.objects.get(code="roads") + cfg = build_conditional_filter_config([thematic_roads], user=self.user_ext) + roads_filters = cfg.get('roads', []) + muni_filter = next((f for f in roads_filters if f['key'] == 'municipality'), None) + self.assertIsNotNone(muni_filter) + opt_values = {opt['value'] for opt in muni_filter['options']} + self.assertIn(str(self.muni_a.id), opt_values) + self.assertIn(str(self.muni_b.id), opt_values) + self.assertNotIn(str(self.muni_c.id), opt_values) + + def test_roads_streets_geojson_restriction(self): + from assets.models.roads import RoadStreet + from django.urls import reverse + + street_a = RoadStreet.objects.create(code="ST-A", name_fr="Rue A", municipality=self.muni_a) + street_c = RoadStreet.objects.create(code="ST-C", name_fr="Rue C", municipality=self.muni_c) + + self.client.force_login(self.user_ext) + response = self.client.get(reverse('assets:roads_streets_geojson')) + self.assertEqual(response.status_code, 200) + data = response.json() + feature_ids = [f['id'] for f in data.get('features', [])] + self.assertIn(street_a.id, feature_ids) + self.assertNotIn(street_c.id, feature_ids) diff --git a/loko/assets/views/artworks.py b/loko/assets/views/artworks.py index 5505518..251003d 100644 --- a/loko/assets/views/artworks.py +++ b/loko/assets/views/artworks.py @@ -65,12 +65,8 @@ def artworks_index(request): "icon": next((t.icon for t in thematics if t.code == thematic), None), } - municipality_order_field = 'name_nl' if lang == 'nl' else 'name_fr' - municipalities = ( - Municipality.objects - .all() - .order_by(municipality_order_field) - ) + from assets.permissions import get_user_accessible_municipalities + municipalities = get_user_accessible_municipalities(request.user) municipality_choices = [(m.id, m.name) for m in municipalities] # Status choices for filters diff --git a/loko/assets/views/core.py b/loko/assets/views/core.py index 5aed2fc..4a29111 100644 --- a/loko/assets/views/core.py +++ b/loko/assets/views/core.py @@ -573,8 +573,8 @@ def build_conditional_filter_config(accessible_thematics, user=None): config = {} # Shared lists used by multiple thematics - from common.models import Municipality - municipalities = Municipality.objects.all().order_by('name_fr') + from assets.permissions import get_user_accessible_municipalities + municipalities = get_user_accessible_municipalities(user) municipality_options = [ {'value': str(m.id), 'label': m.name} for m in municipalities diff --git a/loko/assets/views/publiclighting.py b/loko/assets/views/publiclighting.py index 66dcbf0..894f0c6 100644 --- a/loko/assets/views/publiclighting.py +++ b/loko/assets/views/publiclighting.py @@ -62,7 +62,8 @@ def publiclighting_index(request): "icon": next((t.icon for t in thematics if t.code == thematic), None), } - municipalities = list(Municipality.objects.all()) + from assets.permissions import get_user_accessible_municipalities + municipalities = list(get_user_accessible_municipalities(request.user)) municipalities.sort(key=lambda m: m.name.lower()) municipality_choices = [(m.id, m.name) for m in municipalities] diff --git a/loko/assets/views/roads.py b/loko/assets/views/roads.py index 0a9aed1..dd95959 100644 --- a/loko/assets/views/roads.py +++ b/loko/assets/views/roads.py @@ -65,7 +65,8 @@ def roads_index(request): "icon": next((t.icon for t in thematics if t.code == thematic), None), } - municipalities = list(Municipality.objects.all()) + from assets.permissions import get_user_accessible_municipalities + municipalities = list(get_user_accessible_municipalities(request.user)) municipalities.sort(key=lambda m: m.name.lower()) municipality_choices = [(m.id, m.name) for m in municipalities] @@ -142,11 +143,26 @@ def roads_streets_geojson(request): municipalities = request.GET.getlist('municipality') 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) + # --------- Point d'entrée du queryset qs = RoadStreet.objects.all() - # --------- Filtre par communes - if municipalities: + # --------- Filtre par communes et restriction contractuelle + 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] + qs = qs.filter(municipality_id__in=valid_munis) + else: + qs = qs.filter(municipality_id__in=accessible_muni_ids) + elif municipalities: qs = qs.filter(municipality_id__in=municipalities) # --------- Filtre géométrique @@ -350,7 +366,29 @@ def roads_assets_geojson(request): drains_qs = drains_qs.filter(street__id__in=roads_location_ids) concrete_block_sets_qs = concrete_block_sets_qs.none() - if municipalities: + 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) + + 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] + roadways_qs = roadways_qs.filter(street__municipality_id__in=valid_munis) + sidewalks_qs = sidewalks_qs.filter(street__municipality_id__in=valid_munis) + furnitures_qs = furnitures_qs.filter(street__municipality_id__in=valid_munis) + drains_qs = drains_qs.filter(street__municipality_id__in=valid_munis) + else: + roadways_qs = roadways_qs.filter(street__municipality_id__in=accessible_muni_ids) + sidewalks_qs = sidewalks_qs.filter(street__municipality_id__in=accessible_muni_ids) + furnitures_qs = furnitures_qs.filter(street__municipality_id__in=accessible_muni_ids) + drains_qs = drains_qs.filter(street__municipality_id__in=accessible_muni_ids) + concrete_block_sets_qs = concrete_block_sets_qs.none() + elif municipalities: roadways_qs = roadways_qs.filter(street__municipality_id__in=municipalities) sidewalks_qs = sidewalks_qs.filter(street__municipality_id__in=municipalities) furnitures_qs = furnitures_qs.filter(street__municipality_id__in=municipalities) diff --git a/loko/assets/views/sign.py b/loko/assets/views/sign.py index 0dd2522..8006a28 100644 --- a/loko/assets/views/sign.py +++ b/loko/assets/views/sign.py @@ -70,7 +70,8 @@ def sign_index(request): "icon": next((t.icon for t in thematics if t.code == thematic), None), } - municipalities = list(Municipality.objects.all()) + from assets.permissions import get_user_accessible_municipalities + municipalities = list(get_user_accessible_municipalities(request.user)) municipalities.sort(key=lambda m: m.name.lower()) municipality_choices = [(m.id, m.name) for m in municipalities] diff --git a/loko/common/fixtures/asset_categories.json b/loko/common/fixtures/asset_categories.json new file mode 100644 index 0000000..d71ecbf --- /dev/null +++ b/loko/common/fixtures/asset_categories.json @@ -0,0 +1,828 @@ +[ + { + "code": "TL_ARMOIRE", + "name_fr": "Armoire Tricolore", + "name_nl": "Armoire Tricolore", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightelectricalcabinet" + ] + }, + { + "code": "TL_BORNIER", + "name_fr": "Bornier Tricolore", + "name_nl": "Bornier Tricolore", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightaccessory" + ] + }, + { + "code": "TL_CABLE", + "name_fr": "Cable de Carrefour", + "name_nl": "Cable de Carrefour", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightcable" + ] + }, + { + "code": "TL_CARREFOUR", + "name_fr": "Carrefour Tricolore", + "name_nl": "Carrefour Tricolore", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TL_CONE", + "name_fr": "Cône Balisage", + "name_nl": "Cône Balisage", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TL_DETEC_OTHER", + "name_fr": "Détecteurs autres", + "name_nl": "Andere detectoren", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightdetector" + ] + }, + { + "code": "TL_DETEC_BP", + "name_fr": "Détection Bouton Poussoir", + "name_nl": "Détection Bouton Poussoir", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightdetector" + ] + }, + { + "code": "TL_DETEC_RADAR", + "name_fr": "Détection Radar", + "name_nl": "Détection Radar", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightdetector" + ] + }, + { + "code": "TL_DIVERS", + "name_fr": "Equipement divers", + "name_nl": "Equipement divers", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightaccessory" + ] + }, + { + "code": "TL_LANELIGHT", + "name_fr": "Lanelight Balisage", + "name_nl": "Lanelight Balisage", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TL_LANTERNE", + "name_fr": "Lanterne Tricolore", + "name_nl": "Lanterne Tricolore", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightlantern" + ] + }, + { + "code": "TL_NETWERK", + "name_fr": "Réseau", + "name_nl": "Réseau", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TL_PANNEAU", + "name_fr": "Panneau signalisation", + "name_nl": "Panneau signalisation", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TL_POTEAU", + "name_fr": "Poteau Tricolore", + "name_nl": "Poteau Tricolore", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightpole" + ] + }, + { + "code": "TL_RADAR_PREVENTIF", + "name_fr": "Radar Préventif", + "name_nl": "Radar Préventif", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightradar" + ] + }, + { + "code": "TL_REGELAAR", + "name_fr": "Contrôleur de trafic", + "name_nl": "Traffic regelaar", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightcontroller" + ] + }, + { + "code": "TL_SPANNING", + "name_fr": "Transformateur électrique", + "name_nl": "Transformateur électrique", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "LI_poles", + "name_fr": "Poteaux d'éclairage", + "name_nl": "Verlichtingsmasten", + "thematic_code": "publiclighting", + "parent_code": null, + "allowed_models": [ + "publiclightingpole" + ] + }, + { + "code": "TU_BAL", + "name_fr": "Balisage", + "name_nl": "Balisage", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TU_BAR", + "name_fr": "Barrière", + "name_nl": "Barrière", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_BDI", + "name_fr": "Detection et protection incendie", + "name_nl": "Detection et protection incendie", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_BTS", + "name_fr": "Basse tension", + "name_nl": "Basse tension", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_CAB", + "name_fr": "Cables", + "name_nl": "Cables", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TU_CAC", + "name_fr": "Contrôle d'accès", + "name_nl": "Contrôle d'accès", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_CPT", + "name_fr": "Comptage vehicules", + "name_nl": "Comptage vehicules", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_CTV", + "name_fr": "Videosurveillance", + "name_nl": "Videosurveillance", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_ECL", + "name_fr": "Eclairage", + "name_nl": "Eclairage", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_GAB", + "name_fr": "Detection hors gabarit", + "name_nl": "Detection hors gabarit", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TU_GTC", + "name_fr": "Telegestion", + "name_nl": "Telegestion", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_HTS", + "name_fr": "Haute tension", + "name_nl": "Haute tension", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_LOC", + "name_fr": "Locaux techniques", + "name_nl": "Locaux techniques", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_MET", + "name_fr": "Métalleries", + "name_nl": "Métalleries", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TU_NBK", + "name_fr": "Groupes no break", + "name_nl": "Groupes no break", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TU_PMP", + "name_fr": "Pompage", + "name_nl": "Pompage", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_PMV", + "name_fr": "Panneaux à Messages Variables", + "name_nl": "Panneaux à Messages Variables", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TU_RAD", + "name_fr": "Retransmission radio", + "name_nl": "Retransmission radio", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_SIG", + "name_fr": "Signalisation routiere", + "name_nl": "Signalisation routiere", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_SON", + "name_fr": "Sonorisation", + "name_nl": "Sonorisation", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_TEL", + "name_fr": "Telephonie", + "name_nl": "Telephonie", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_VEN", + "name_fr": "Ventilation", + "name_nl": "Ventilation", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "CC_BUILDING", + "name_fr": "Bâtiment", + "name_nl": "Gebouw", + "thematic_code": "controlcenters", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CC_MAINTENANCE", + "name_fr": "Maintenance", + "name_nl": "Onderhoud", + "thematic_code": "controlcenters", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CC_OFFICE_EQMNT", + "name_fr": "Bureautique", + "name_nl": "Bureautique", + "thematic_code": "controlcenters", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CC_SYSTEM", + "name_fr": "Système", + "name_nl": "Systeem", + "thematic_code": "controlcenters", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CC_TELEPHONY", + "name_fr": "Téléphonie", + "name_nl": "Telefonie", + "thematic_code": "controlcenters", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CC_VIDEO", + "name_fr": "Vidéo", + "name_nl": "Video", + "thematic_code": "controlcenters", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "LI_lightfixtures", + "name_fr": "Luminaires", + "name_nl": "Verlichtingsarmaturen", + "thematic_code": "publiclighting", + "parent_code": null, + "allowed_models": [ + "publiclightinglightfixture" + ] + }, + { + "code": "LI_electrical_cabinets", + "name_fr": "Armoires électriques", + "name_nl": "Elektrische kasten", + "thematic_code": "publiclighting", + "parent_code": null, + "allowed_models": [ + "publiclightingelectricalcabinet" + ] + }, + { + "code": "LI_cables", + "name_fr": "Câbles", + "name_nl": "Kabels", + "thematic_code": "publiclighting", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "ITS_CAMERA", + "name_fr": "Caméras", + "name_nl": "Cameras", + "thematic_code": "its", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "ITS_COUNTING", + "name_fr": "Comptages", + "name_nl": "Tellingen", + "thematic_code": "its", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "ITS_LIDAR", + "name_fr": "Lidar", + "name_nl": "Lidar", + "thematic_code": "its", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "ITS_NETWORK", + "name_fr": "Réseau", + "name_nl": "Netwerk", + "thematic_code": "its", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "ITS_PMV", + "name_fr": "PMV", + "name_nl": "PMV", + "thematic_code": "its", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "ITS_SYSTEM", + "name_fr": "Systèmes", + "name_nl": "Systemen", + "thematic_code": "its", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "NAT_GREEN_AREAS", + "name_fr": "Espaces verts", + "name_nl": "Groene ruimtes", + "thematic_code": "nature", + "parent_code": null, + "allowed_models": [ + "naturegreensurface" + ] + }, + { + "code": "NAT_TREES", + "name_fr": "Arbres", + "name_nl": "Bomen", + "thematic_code": "nature", + "parent_code": null, + "allowed_models": [ + "naturetree" + ] + }, + { + "code": "RD_ROADWAY", + "name_fr": "Chaussée", + "name_nl": "Rijbaan", + "thematic_code": "roads", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "RD_SIDEWALK", + "name_fr": "Trottoir", + "name_nl": "Voetpad", + "thematic_code": "roads", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "RD_CYCLEROAD", + "name_fr": "Piste cyclable", + "name_nl": "Fietspad", + "thematic_code": "roads", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TU_ROADWAY", + "name_fr": "Chaussée en tunnel", + "name_nl": "Tunnelweg", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TU_CIVIL_ENGINEERING", + "name_fr": "Génie civil", + "name_nl": "Burgerlijke bouwkunde", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "TU_HYDRANT", + "name_fr": "Hydrant", + "name_nl": "Hydrant", + "thematic_code": "structures", + "parent_code": null, + "allowed_models": [ + "structuregeoasset" + ] + }, + { + "code": "LI_CONE", + "name_fr": "Cône", + "name_nl": "Kegel", + "thematic_code": "publiclighting", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "ART_FONTAIN", + "name_fr": "Fontaines", + "name_nl": "Fonteinen", + "thematic_code": "artworks", + "parent_code": null, + "allowed_models": [ + "artworkfountain" + ] + }, + { + "code": "ART_MONUMENT", + "name_fr": "Monument", + "name_nl": "Monument", + "thematic_code": "artworks", + "parent_code": null, + "allowed_models": [ + "artworkmonument" + ] + }, + { + "code": "RD_DRAIN", + "name_fr": "Avaloir", + "name_nl": "Rioolput", + "thematic_code": "roads", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "RD_MANHOLE", + "name_fr": "Taque", + "name_nl": "Riooldeksel", + "thematic_code": "roads", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "SI_POLE", + "name_fr": "Poteau", + "name_nl": "Paal", + "thematic_code": "sign", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "SI_PANEL", + "name_fr": "Panneau", + "name_nl": "Paneel", + "thematic_code": "sign", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "SI_BARRIER", + "name_fr": "Barrière", + "name_nl": "Barrière", + "thematic_code": "sign", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "SI_MARKING", + "name_fr": "Marquage", + "name_nl": "Markering", + "thematic_code": "sign", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "RD_STREET_FURNITURE", + "name_fr": "Mobilier urbain", + "name_nl": "Straatmeubilair", + "thematic_code": "roads", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TL_SIBELGA_METER", + "name_fr": "Compteur Sibelga", + "name_nl": "Sibelga-meter", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "SI_TAXI", + "name_fr": "Emplacement Taxi", + "name_nl": "Taxistandplaats", + "thematic_code": "sign", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "TL_DETEC_VIDEO", + "name_fr": "Détection Vidéo", + "name_nl": "Videodetectie", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightdetector" + ] + }, + { + "code": "ITS_RADARS", + "name_fr": "Radars", + "name_nl": "Radars", + "thematic_code": "its", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "NAT_FURNITURES", + "name_fr": "Mobilier (Bacs à fleurs, ...)", + "name_nl": "Meubilair (bloembakken, ...)", + "thematic_code": "nature", + "parent_code": null, + "allowed_models": [ + "naturebench", + "natureshrub" + ] + }, + { + "code": "CL_MATERIALS", + "name_fr": "Matériaux", + "name_nl": "Materialen", + "thematic_code": "clean", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CL_DRAIN", + "name_fr": "Avaloir", + "name_nl": "Rioolput", + "thematic_code": "clean", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CL_OTHER", + "name_fr": "Autre", + "name_nl": "Andere", + "thematic_code": "clean", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CL_ROADWAY", + "name_fr": "Chaussée", + "name_nl": "Rijbaan", + "thematic_code": "clean", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CL_BUBBLE", + "name_fr": "Bulle", + "name_nl": "Bubbel", + "thematic_code": "clean", + "parent_code": null, + "allowed_models": [ + "cleanglasscontainer" + ] + }, + { + "code": "CL_BASKET", + "name_fr": "Corbeille", + "name_nl": "Prullenbak", + "thematic_code": "clean", + "parent_code": null, + "allowed_models": [ + "cleanlitterbin" + ] + }, + { + "code": "ITS_POWER_SUPPLY", + "name_fr": "Alimentation électrique", + "name_nl": "Elektrische voeding", + "thematic_code": "its", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CL_SIDEWALK", + "name_fr": "Trottoir", + "name_nl": "Voetpad", + "thematic_code": "clean", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "CL_BIKELANE", + "name_fr": "Piste cyclable", + "name_nl": "Fietspad", + "thematic_code": "clean", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "NAT_RWI", + "name_fr": "Gestion intégrée des eaux pluviales (GIEP)", + "name_nl": "Geïntegreerd regenwaterbeheer (GRWB)", + "thematic_code": "water", + "parent_code": null, + "allowed_models": [ + "naturerwiasset" + ] + }, + { + "code": "TL_RADAR_POLE", + "name_fr": "Poteau radar", + "name_nl": "Radarpaal", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightradarpole" + ] + }, + { + "code": "parking_spot", + "name_fr": "Emplacements de stationnement", + "name_nl": "Parkeerplaatsen", + "thematic_code": "parking", + "parent_code": null, + "allowed_models": [] + }, + { + "code": "bike_parking_spot", + "name_fr": "Emplacement vélos", + "name_nl": "Fietsenstalling", + "thematic_code": "parking", + "parent_code": null, + "allowed_models": [ + "parkinglocation", + "parkingspot" + ] + }, + { + "code": "TL_PROGRAMMING", + "name_fr": "Programmation", + "name_nl": "Programmatie", + "thematic_code": "trafficlights", + "parent_code": null, + "allowed_models": [ + "trafficlightprogramming" + ] + } +] \ No newline at end of file diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index a0c641f..710ba28 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -126,17 +126,17 @@ class CompanyAdmin(admin.ModelAdmin): @admin.register(Contract) class ContractAdmin(admin.ModelAdmin): - list_display = ('contract_number', 'description', 'company', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'thematics_list') + list_display = ('contract_number', 'description', 'company', 'municipalities_list', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'thematics_list') search_fields = ('contract_number', 'company__name', 'description', 'contact_phone') - list_filter = ('is_blocked', 'is_active', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'send_daily_summary') - filter_horizontal = ('thematics',) + list_filter = ('is_blocked', 'is_active', 'municipalities', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'send_daily_summary') + filter_horizontal = ('thematics', 'municipalities') def get_inlines(self, request, obj=None): return [_get_pause_reason_inline(), _get_symptom_autoclose_inline(), _get_operation_template_posts_inline()] + list(super().get_inlines(request, obj)) fieldsets = ( (None, { - 'fields': ('company', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'), + 'fields': ('company', 'municipalities', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'), }), ('Blocage budgétaire / Suspension', { 'fields': ('is_blocked', 'blocked_reason', 'blocked_at'), @@ -164,6 +164,10 @@ class ContractAdmin(admin.ModelAdmin): } js = ('contracts/js/contract_admin.js',) + def municipalities_list(self, obj): + return ", ".join([m.name for m in obj.municipalities.all()]) + municipalities_list.short_description = 'Communes' + def thematics_list(self, obj): return ", ".join([thematic.name for thematic in obj.thematics.all()]) thematics_list.short_description = 'Thématiques' diff --git a/loko/contracts/migrations/0010_contract_municipality.py b/loko/contracts/migrations/0010_contract_municipality.py new file mode 100644 index 0000000..89226f5 --- /dev/null +++ b/loko/contracts/migrations/0010_contract_municipality.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.7 on 2026-09-19 13:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0015_remove_obsolete_schools_appview'), + ('contracts', '0009_contract_auto_assign_control_team'), + ] + + operations = [ + migrations.AddField( + model_name='contract', + name='municipality', + field=models.ForeignKey(blank=True, help_text='Commune couverte par ce contrat (permet le cadrage cartographique automatique).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to='common.municipality', verbose_name='Commune associée'), + ), + ] diff --git a/loko/contracts/migrations/0011_contract_municipalities.py b/loko/contracts/migrations/0011_contract_municipalities.py new file mode 100644 index 0000000..fc8cadd --- /dev/null +++ b/loko/contracts/migrations/0011_contract_municipalities.py @@ -0,0 +1,46 @@ +# Generated manually for multi-commune contracts + +from django.db import migrations, models + + +def copy_municipality_to_municipalities(apps, schema_editor): + Contract = apps.get_model('contracts', 'Contract') + for contract in Contract.objects.all(): + if getattr(contract, 'municipality_id', None): + contract.municipalities.add(contract.municipality_id) + + +def reverse_copy(apps, schema_editor): + Contract = apps.get_model('contracts', 'Contract') + for contract in Contract.objects.all(): + first_m = contract.municipalities.first() + if first_m: + contract.municipality_id = first_m.id + contract.save(update_fields=['municipality_id']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0015_remove_obsolete_schools_appview'), + ('contracts', '0010_contract_municipality'), + ] + + operations = [ + migrations.AddField( + model_name='contract', + name='municipalities', + field=models.ManyToManyField( + blank=True, + help_text='Communes couvertes par ce contrat (permet le filtrage et le cadrage cartographique).', + related_name='contracts', + to='common.municipality', + verbose_name='Communes associées', + ), + ), + migrations.RunPython(copy_municipality_to_municipalities, reverse_code=reverse_copy), + migrations.RemoveField( + model_name='contract', + name='municipality', + ), + ] diff --git a/loko/contracts/models.py b/loko/contracts/models.py index ec5aa11..f35903f 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -372,6 +372,18 @@ class Contract(models.Model): verbose_name=_("Thématiques"), help_text=_("Thématiques associées à ce contrat."), ) + municipalities = models.ManyToManyField( + 'common.Municipality', + blank=True, + related_name="contracts", + verbose_name=_("Communes associées"), + help_text=_("Communes couvertes par ce contrat (permet le filtrage et le cadrage cartographique).") + ) + + @property + def municipality(self): + """Retourne la première commune associée (compatibilité ascendante).""" + return self.municipalities.first() is_active = models.BooleanField( default=True, verbose_name=_("Actif"),