Compare commits

..

2 commits

16 changed files with 1576 additions and 27 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) 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): if os.path.exists(tmp_path):
os.remove(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): class AssetDefaultPositionTest(TestCase):
def test_assign_default_position_hierarchy(self): 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), "icon": next((t.icon for t in thematics if t.code == thematic), None),
} }
municipality_order_field = 'name_nl' if lang == 'nl' else 'name_fr' from assets.permissions import get_user_accessible_municipalities
municipalities = ( municipalities = get_user_accessible_municipalities(request.user)
Municipality.objects
.all()
.order_by(municipality_order_field)
)
municipality_choices = [(m.id, m.name) for m in municipalities] municipality_choices = [(m.id, m.name) for m in municipalities]
# Status choices for filters # Status choices for filters

View file

@ -573,8 +573,8 @@ def build_conditional_filter_config(accessible_thematics, user=None):
config = {} config = {}
# Shared lists used by multiple thematics # Shared lists used by multiple thematics
from common.models import Municipality from assets.permissions import get_user_accessible_municipalities
municipalities = Municipality.objects.all().order_by('name_fr') municipalities = get_user_accessible_municipalities(user)
municipality_options = [ municipality_options = [
{'value': str(m.id), 'label': m.name} {'value': str(m.id), 'label': m.name}
for m in municipalities 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), "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()) municipalities.sort(key=lambda m: m.name.lower())
municipality_choices = [(m.id, m.name) for m in municipalities] 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), "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()) municipalities.sort(key=lambda m: m.name.lower())
municipality_choices = [(m.id, m.name) for m in municipalities] municipality_choices = [(m.id, m.name) for m in municipalities]
@ -142,11 +143,26 @@ def roads_streets_geojson(request):
municipalities = request.GET.getlist('municipality') municipalities = request.GET.getlist('municipality')
filter_geom = None filter_geom = None
from assets.permissions import get_user_accessible_municipalities
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
# --------- Point d'entrée du queryset # --------- Point d'entrée du queryset
qs = RoadStreet.objects.all() qs = RoadStreet.objects.all()
# --------- Filtre par communes # --------- Filtre par communes et restriction contractuelle
if municipalities: 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) qs = qs.filter(municipality_id__in=municipalities)
# --------- Filtre géométrique # --------- Filtre géométrique
@ -350,7 +366,29 @@ def roads_assets_geojson(request):
drains_qs = drains_qs.filter(street__id__in=roads_location_ids) drains_qs = drains_qs.filter(street__id__in=roads_location_ids)
concrete_block_sets_qs = concrete_block_sets_qs.none() 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) roadways_qs = roadways_qs.filter(street__municipality_id__in=municipalities)
sidewalks_qs = sidewalks_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) 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), "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()) municipalities.sort(key=lambda m: m.name.lower())
municipality_choices = [(m.id, m.name) for m in municipalities] municipality_choices = [(m.id, m.name) for m in municipalities]

View file

@ -4,6 +4,7 @@ import os
from typing import Iterable, List from typing import Iterable, List
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
from django.urls import resolve from django.urls import resolve
from .models import UserConfig, AppView from .models import UserConfig, AppView
@ -11,6 +12,7 @@ from .models import UserConfig, AppView
SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids" SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids"
SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id" SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id"
SESSION_MAP_BOUNDS_KEY = "user_map_bounds"
from django.conf import settings from django.conf import settings
@ -55,6 +57,7 @@ def current_app_view(request):
Only 'sign' and 'documents' have their own dedicated views. Only 'sign' and 'documents' have their own dedicated views.
All other apps (assets, interventions, controls, contracts, etc.) use 'asset_management' view. All other apps (assets, interventions, controls, contracts, etc.) use 'asset_management' view.
Results are cached to avoid hitting the database on every page render.
""" """
current_view = None current_view = None
@ -67,13 +70,20 @@ def current_app_view(request):
namespace = resolved.namespace # e.g., 'sign', 'home', 'assets' namespace = resolved.namespace # e.g., 'sign', 'home', 'assets'
if namespace: if namespace:
if namespace in DEDICATED_VIEW_APPS: cache_key = f"appview_for_{namespace}"
# These apps have their own dedicated view cached_view = cache.get(cache_key)
current_view = AppView.objects.filter(url_name__startswith=f"{namespace}:").first() if cached_view is not None:
current_view = None if cached_view is False else cached_view
else: else:
# All other apps (home, assets, interventions, controls, contracts, etc.) if namespace in DEDICATED_VIEW_APPS:
# should show 'asset_management' view # These apps have their own dedicated view
current_view = AppView.objects.filter(code='asset_management').first() current_view = AppView.objects.filter(url_name__startswith=f"{namespace}:").first()
else:
# All other apps (home, assets, interventions, controls, contracts, etc.)
# should show 'asset_management' view
current_view = AppView.objects.filter(code='asset_management').first()
cache.set(cache_key, current_view if current_view is not None else False, 3600)
except Exception: except Exception:
pass pass
@ -88,6 +98,7 @@ def linked_accounts(request):
if not user or isinstance(user, AnonymousUser) or not user.is_authenticated: if not user or isinstance(user, AnonymousUser) or not user.is_authenticated:
request.session.pop(SESSION_ALLOWED_IDS_KEY, None) request.session.pop(SESSION_ALLOWED_IDS_KEY, None)
request.session.pop(SESSION_ORIGIN_ID_KEY, None) request.session.pop(SESSION_ORIGIN_ID_KEY, None)
request.session.pop(SESSION_MAP_BOUNDS_KEY, None)
return { return {
"switchable_accounts": [], "switchable_accounts": [],
"account_switch_origin_user_id": None, "account_switch_origin_user_id": None,
@ -98,6 +109,7 @@ def linked_accounts(request):
except UserConfig.DoesNotExist: except UserConfig.DoesNotExist:
request.session.pop(SESSION_ALLOWED_IDS_KEY, None) request.session.pop(SESSION_ALLOWED_IDS_KEY, None)
request.session.pop(SESSION_ORIGIN_ID_KEY, None) request.session.pop(SESSION_ORIGIN_ID_KEY, None)
request.session.pop(SESSION_MAP_BOUNDS_KEY, None)
return { return {
"switchable_accounts": [], "switchable_accounts": [],
"account_switch_origin_user_id": None, "account_switch_origin_user_id": None,
@ -140,15 +152,83 @@ def linked_accounts(request):
def organization_info(request): def organization_info(request):
""" """
Context processor that exposes organization details from settings. Context processor that exposes organization details from settings.
Dynamically adapts map_default_center, map_default_zoom and map_default_extent
when the authenticated user has an assigned contract associated with a municipality.
Caches calculated bounds in session to avoid heavy GIS and SQL processing on every page.
""" """
center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)]
zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11)
extent = getattr(settings, 'MAP_DEFAULT_EXTENT', None)
if hasattr(request, 'user') and request.user.is_authenticated:
session = getattr(request, 'session', None)
cached_bounds = session.get(SESSION_MAP_BOUNDS_KEY) if session is not None else None
if cached_bounds and cached_bounds.get('user_id') == request.user.pk:
center = cached_bounds.get('center', center)
zoom = cached_bounds.get('zoom', zoom)
extent = cached_bounds.get('extent', extent)
else:
try:
user_config = getattr(request.user, 'config', None)
if user_config:
# Récupérer les contrats accessibles ayant des communes associées
contracts_to_check = []
if user_config.default_contract and user_config.default_contract.municipalities.exists():
contracts_to_check.append(user_config.default_contract)
else:
contracts_to_check = list(user_config.contracts.prefetch_related('municipalities').filter(municipalities__isnull=False).distinct())
geoms_4326 = []
for c in contracts_to_check:
for m in c.municipalities.exclude(geom=None):
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
geoms_4326.append(g)
if geoms_4326:
min_lon = min(g.extent[0] for g in geoms_4326)
min_lat = min(g.extent[1] for g in geoms_4326)
max_lon = max(g.extent[2] for g in geoms_4326)
max_lat = max(g.extent[3] for g in geoms_4326)
# Léger buffer (5% d'expansion de chaque côté pour aérer le cadre)
delta_lon = (max_lon - min_lon) * 0.05
delta_lat = (max_lat - min_lat) * 0.05
center = [round((min_lon + max_lon) / 2.0, 5), round((min_lat + max_lat) / 2.0, 5)]
extent = [
[round(min_lon - delta_lon, 5), round(min_lat - delta_lat, 5)],
[round(max_lon + delta_lon, 5), round(max_lat + delta_lat, 5)]
]
span = max(max_lon - min_lon, max_lat - min_lat)
if span < 0.08:
zoom = 14
elif span < 0.2:
zoom = 13
elif span < 0.5:
zoom = 12
else:
zoom = 11
if session is not None:
session[SESSION_MAP_BOUNDS_KEY] = {
'user_id': request.user.pk,
'center': center,
'zoom': zoom,
'extent': extent,
}
except Exception:
pass
return { return {
'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'), 'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'),
'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'), 'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'),
'organization_logo_header': getattr(settings, 'ORGANIZATION_LOGO_HEADER', 'common/android-192.png'), 'organization_logo_header': getattr(settings, 'ORGANIZATION_LOGO_HEADER', 'common/android-192.png'),
'organization_primary_color': getattr(settings, 'ORGANIZATION_PRIMARY_COLOR_HEX', '#2f3e8b'), 'organization_primary_color': getattr(settings, 'ORGANIZATION_PRIMARY_COLOR_HEX', '#2f3e8b'),
'map_default_center': [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)], 'map_default_center': center,
'map_default_zoom': getattr(settings, 'MAP_DEFAULT_ZOOM', 11), 'map_default_zoom': zoom,
'map_default_extent': getattr(settings, 'MAP_DEFAULT_EXTENT', None), 'map_default_extent': extent,
'map_overlay_wms_url': getattr(settings, 'MAP_OVERLAY_WMS_URL', ''), 'map_overlay_wms_url': getattr(settings, 'MAP_OVERLAY_WMS_URL', ''),
'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''), 'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''),
'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''), 'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''),

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

@ -390,8 +390,55 @@ class ScheduledCommandResolveOutputFileTests(TestCase):
mock_attach.assert_called_once_with(fake_path) mock_attach.assert_called_once_with(fake_path)
mock_send.assert_called_once() mock_send.assert_called_once()
from django.test import RequestFactory
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.cache import cache
from common.context_processors import (
current_app_view,
organization_info,
SESSION_MAP_BOUNDS_KEY,
)
from common.models import AppView
class ContextProcessorsCachingTests(TestCase):
def setUp(self):
cache.clear()
self.factory = RequestFactory()
self.user = User.objects.create_user(username="context_user", password="password123")
self.config = UserConfig.objects.create(user=self.user)
def _add_session_to_request(self, request):
middleware = SessionMiddleware(lambda req: None)
middleware.process_request(request)
request.session.save()
def test_current_app_view_caches_result(self):
AppView.objects.create(code="asset_management", name_fr="Gestion des actifs", name_nl="Activa beheer", url_name="home:index")
request = self.factory.get("/")
with self.assertNumQueries(1):
ctx1 = current_app_view(request)
self.assertIsNotNone(ctx1["current_view"])
# Second call should use cache and perform 0 database queries
with self.assertNumQueries(0):
ctx2 = current_app_view(request)
self.assertEqual(ctx1["current_view"], ctx2["current_view"])
def test_organization_info_caches_in_session(self):
request = self.factory.get("/")
self._add_session_to_request(request)
request.user = self.user
ctx1 = organization_info(request)
self.assertIn(SESSION_MAP_BOUNDS_KEY, request.session)
self.assertEqual(request.session[SESSION_MAP_BOUNDS_KEY]["user_id"], self.user.pk)
# Subsequent call with session already containing user_map_bounds does not query or recalculate
with self.assertNumQueries(0):
ctx2 = organization_info(request)
self.assertEqual(ctx1["map_default_center"], ctx2["map_default_center"])
self.assertEqual(ctx1["map_default_zoom"], ctx2["map_default_zoom"])
self.assertEqual(ctx1["map_default_extent"], ctx2["map_default_extent"])

View file

@ -22,6 +22,7 @@ from urllib.parse import urlencode
from common.context_processors import ( from common.context_processors import (
SESSION_ALLOWED_IDS_KEY, SESSION_ALLOWED_IDS_KEY,
SESSION_ORIGIN_ID_KEY, SESSION_ORIGIN_ID_KEY,
SESSION_MAP_BOUNDS_KEY,
compute_initial_allowed_ids, compute_initial_allowed_ids,
) )
@ -287,6 +288,7 @@ def switch_account(request):
session[SESSION_ALLOWED_IDS_KEY] = allowed_ids session[SESSION_ALLOWED_IDS_KEY] = allowed_ids
session[SESSION_ORIGIN_ID_KEY] = origin_user_id session[SESSION_ORIGIN_ID_KEY] = origin_user_id
session.pop(SESSION_MAP_BOUNDS_KEY, None)
return redirect(origin_next or reverse("home:index")) return redirect(origin_next or reverse("home:index"))

View file

@ -126,17 +126,17 @@ class CompanyAdmin(admin.ModelAdmin):
@admin.register(Contract) @admin.register(Contract)
class ContractAdmin(admin.ModelAdmin): 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') 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') 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',) filter_horizontal = ('thematics', 'municipalities')
def get_inlines(self, request, obj=None): 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)) return [_get_pause_reason_inline(), _get_symptom_autoclose_inline(), _get_operation_template_posts_inline()] + list(super().get_inlines(request, obj))
fieldsets = ( fieldsets = (
(None, { (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', { ('Blocage budgétaire / Suspension', {
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'), 'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
@ -164,6 +164,10 @@ class ContractAdmin(admin.ModelAdmin):
} }
js = ('contracts/js/contract_admin.js',) 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): def thematics_list(self, obj):
return ", ".join([thematic.name for thematic in obj.thematics.all()]) return ", ".join([thematic.name for thematic in obj.thematics.all()])
thematics_list.short_description = 'Thématiques' 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"), verbose_name=_("Thématiques"),
help_text=_("Thématiques associées à ce contrat."), 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( is_active = models.BooleanField(
default=True, default=True,
verbose_name=_("Actif"), verbose_name=_("Actif"),