feat: support multi-commune contracts, base asset categories, and filtered municipality access

This commit is contained in:
kdeterme 2026-09-19 18:33:39 +02:00
parent e68e6ccefe
commit 73738b6757
13 changed files with 1438 additions and 18 deletions

View file

@ -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}")

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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)

View file

@ -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]

View file

@ -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"
]
}
]

View file

@ -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'

View file

@ -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'),
),
]

View file

@ -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',
),
]

View file

@ -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"),