This commit is contained in:
Karl Determe 2026-09-24 11:04:00 +02:00
commit f19eae6067
193 changed files with 17278 additions and 2869 deletions

View file

@ -47,7 +47,7 @@ ENV PROJ_DATA=/opt/conda/envs/loko-env/share/proj
# Passer INSTALL_AI_LIBS=true dans le docker-compose pour les inclure
ARG INSTALL_AI_LIBS=false
# Copie et installation des dépendances Python
# Copie et installation des dépendances Python (mis en cache tant que requirements/ ne change pas)
COPY requirements/ /app/requirements/
RUN pip install --no-cache-dir -r requirements/base.txt && \
if [ "$INSTALL_AI_LIBS" = "true" ]; then \
@ -81,6 +81,7 @@ COPY loko/mobile/ /app/mobile/
COPY loko/observations/ /app/observations/
COPY loko/controls/ /app/controls/
COPY loko/interventions/ /app/interventions/
COPY loko/inspections/ /app/inspections/
COPY loko/assets/ /app/assets/
COPY loko/panoramax/ /app/panoramax/
@ -100,6 +101,15 @@ COPY loko/road_detection/templates/ /app/road_detection/templates/
COPY loko/*.py /app/
COPY loko/generated_passwords.txt* /app/
# Métadonnées de build dynamiques (placées à la fin pour préserver le cache de build Docker)
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
ARG LOKO_VERSION=1.0.0
ENV GIT_COMMIT_SHA=${GIT_COMMIT}
ENV BUILD_DATE=${BUILD_DATE}
ENV LOKO_VERSION=${LOKO_VERSION}
# Exposer le port par défaut
EXPOSE 8000

View file

@ -1,4 +1,5 @@
from django.urls import path, re_path, include
from .views_version import VersionInfoView
from .views import (
GeoProxyView,
InterventionListCreateView,
@ -10,6 +11,7 @@ from .views import (
app_name = "zapi"
urlpatterns = [
path("version/", VersionInfoView.as_view(), name="system-version"),
# API root (optionnel: une vue d'info/health)
# path("", APIRootView.as_view(), name="root"),

59
loko/api/views_version.py Normal file
View file

@ -0,0 +1,59 @@
import os
import json
import subprocess
from django.http import JsonResponse
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
def get_version_info():
version = os.environ.get('LOKO_VERSION', '1.0.0')
commit_sha = os.environ.get('GIT_COMMIT_SHA') or os.environ.get('GIT_COMMIT')
branch = os.environ.get('GIT_BRANCH', 'main')
build_date = os.environ.get('BUILD_DATE')
if not commit_sha:
for possible_path in ['/app/build_info.json', 'build_info.json']:
if os.path.exists(possible_path):
try:
with open(possible_path, 'r', encoding='utf-8') as f:
data = json.load(f)
commit_sha = data.get('git_commit') or data.get('commit_sha')
version = data.get('version', version)
branch = data.get('branch', branch)
build_date = data.get('build_date', build_date)
break
except Exception:
pass
if not commit_sha:
try:
commit_sha = subprocess.check_output(
['git', 'rev-parse', 'HEAD'],
stderr=subprocess.DEVNULL,
timeout=1
).decode('utf-8').strip()
except Exception:
pass
commit_short = commit_sha[:7] if commit_sha else None
return {
'status': 'online',
'app': 'loko',
'version': version,
'git_commit': commit_sha,
'git_commit_short': commit_short,
'branch': branch,
'build_date': build_date,
'timestamp': timezone.now().isoformat()
}
class VersionInfoView(APIView):
permission_classes = [AllowAny]
authentication_classes = []
def get(self, request, *args, **kwargs):
return JsonResponse(get_version_info())

View file

@ -613,3 +613,107 @@ msgstr "Collapse models"
#: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr ""
#: loko/assets/static/assets/inspection_map.js:93
msgid "Rechercher une adresse…"
msgstr "Search an address…"
msgid "Rechercher une adresse..."
msgstr "Search an address..."
#: loko/assets/static/assets/inspection_map.js:748
msgid "Géolocalisation..."
msgstr "Geolocation..."
#: loko/assets/static/assets/inspection_map.js:769
msgid "Position actualisée"
msgstr "Position updated"
#: loko/assets/static/assets/inspection_map.js:773
msgid "GPS indisponible"
msgstr "GPS unavailable"
#: loko/assets/static/assets/inspection_map.js:935
msgid "Inspections (Désactivé)"
msgstr "Inspections (Disabled)"
#: loko/assets/static/assets/inspection_map.js:936
msgid "Inspections (1 mois)"
msgstr "Inspections (1 month)"
#: loko/assets/static/assets/inspection_map.js:937
msgid "Inspections (3 mois)"
msgstr "Inspections (3 months)"
#: loko/assets/static/assets/inspection_map.js:938
msgid "Inspections (6 mois)"
msgstr "Inspections (6 months)"
#: loko/assets/static/assets/inspection_map.js:939
msgid "Inspections (1 an)"
msgstr "Inspections (1 year)"
#: loko/assets/static/assets/inspection_map.js:940
msgid "Inspections (Tout)"
msgstr "Inspections (All)"
msgid "Panneau"
msgstr "Sign"
msgid "Supprimer ce panneau"
msgstr "Delete this sign"
msgid "Mention (ex: Sauf riverains)"
msgstr "Mention (e.g. Except local traffic)"
msgid "Propreté :"
msgstr "Cleanliness:"
msgid "Propre"
msgstr "Clean"
msgid "Sale"
msgstr "Dirty"
msgid "Végétation"
msgstr "Vegetation"
msgid "Dégradé"
msgstr "Damaged"
msgid "Coordonnées GPS manquantes."
msgstr "Missing GPS coordinates."
msgid "Veuillez ajouter au moins un panneau."
msgstr "Please add at least one sign."
msgid "Panneaux créés avec succès !"
msgstr "Signs created successfully!"
msgid "Erreur : "
msgstr "Error: "
msgid "Impossible de créer les panneaux."
msgstr "Unable to create signs."
msgid "Erreur lors de la création : "
msgstr "Error during creation: "
msgid "Créer et Enregistrer"
msgstr "Create and Save"
msgid "Inspection enregistrée !"
msgstr "Inspection saved!"
msgid "Intervention"
msgstr "Intervention"
msgid "créée."
msgstr "created."
msgid "Erreur lors de la validation."
msgstr "Validation error."
msgid "Erreur réseau."
msgstr "Network error."

View file

@ -618,3 +618,107 @@ msgstr "Replier les modèles"
#: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
#: loko/assets/static/assets/inspection_map.js:93
msgid "Rechercher une adresse…"
msgstr "Rechercher une adresse…"
msgid "Rechercher une adresse..."
msgstr "Rechercher une adresse..."
#: loko/assets/static/assets/inspection_map.js:748
msgid "Géolocalisation..."
msgstr "Géolocalisation..."
#: loko/assets/static/assets/inspection_map.js:769
msgid "Position actualisée"
msgstr "Position actualisée"
#: loko/assets/static/assets/inspection_map.js:773
msgid "GPS indisponible"
msgstr "GPS indisponible"
#: loko/assets/static/assets/inspection_map.js:935
msgid "Inspections (Désactivé)"
msgstr "Inspections (Désactivé)"
#: loko/assets/static/assets/inspection_map.js:936
msgid "Inspections (1 mois)"
msgstr "Inspections (1 mois)"
#: loko/assets/static/assets/inspection_map.js:937
msgid "Inspections (3 mois)"
msgstr "Inspections (3 mois)"
#: loko/assets/static/assets/inspection_map.js:938
msgid "Inspections (6 mois)"
msgstr "Inspections (6 mois)"
#: loko/assets/static/assets/inspection_map.js:939
msgid "Inspections (1 an)"
msgstr "Inspections (1 an)"
#: loko/assets/static/assets/inspection_map.js:940
msgid "Inspections (Tout)"
msgstr "Inspections (Tout)"
msgid "Panneau"
msgstr "Panneau"
msgid "Supprimer ce panneau"
msgstr "Supprimer ce panneau"
msgid "Mention (ex: Sauf riverains)"
msgstr "Mention (ex: Sauf riverains)"
msgid "Propreté :"
msgstr "Propreté :"
msgid "Propre"
msgstr "Propre"
msgid "Sale"
msgstr "Sale"
msgid "Végétation"
msgstr "Végétation"
msgid "Dégradé"
msgstr "Dégradé"
msgid "Coordonnées GPS manquantes."
msgstr "Coordonnées GPS manquantes."
msgid "Veuillez ajouter au moins un panneau."
msgstr "Veuillez ajouter au moins un panneau."
msgid "Panneaux créés avec succès !"
msgstr "Panneaux créés avec succès !"
msgid "Erreur : "
msgstr "Erreur : "
msgid "Impossible de créer les panneaux."
msgstr "Impossible de créer les panneaux."
msgid "Erreur lors de la création : "
msgstr "Erreur lors de la création : "
msgid "Créer et Enregistrer"
msgstr "Créer et Enregistrer"
msgid "Inspection enregistrée !"
msgstr "Inspection enregistrée !"
msgid "Intervention"
msgstr "Intervention"
msgid "créée."
msgstr "créée."
msgid "Erreur lors de la validation."
msgstr "Erreur lors de la validation."
msgid "Erreur réseau."
msgstr "Erreur réseau."

View file

@ -615,3 +615,107 @@ msgstr "Modellen inklappen"
#: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr ""
#: loko/assets/static/assets/inspection_map.js:93
msgid "Rechercher une adresse…"
msgstr "Zoek een adres…"
msgid "Rechercher une adresse..."
msgstr "Zoek een adres..."
#: loko/assets/static/assets/inspection_map.js:748
msgid "Géolocalisation..."
msgstr "Geolocatie..."
#: loko/assets/static/assets/inspection_map.js:769
msgid "Position actualisée"
msgstr "Positie bijgewerkt"
#: loko/assets/static/assets/inspection_map.js:773
msgid "GPS indisponible"
msgstr "GPS niet beschikbaar"
#: loko/assets/static/assets/inspection_map.js:935
msgid "Inspections (Désactivé)"
msgstr "Inspecties (Uitgeschakeld)"
#: loko/assets/static/assets/inspection_map.js:936
msgid "Inspections (1 mois)"
msgstr "Inspecties (1 maand)"
#: loko/assets/static/assets/inspection_map.js:937
msgid "Inspections (3 mois)"
msgstr "Inspecties (3 maanden)"
#: loko/assets/static/assets/inspection_map.js:938
msgid "Inspections (6 mois)"
msgstr "Inspecties (6 maanden)"
#: loko/assets/static/assets/inspection_map.js:939
msgid "Inspections (1 an)"
msgstr "Inspecties (1 jaar)"
#: loko/assets/static/assets/inspection_map.js:940
msgid "Inspections (Tout)"
msgstr "Inspecties (Alles)"
msgid "Panneau"
msgstr "Bord"
msgid "Supprimer ce panneau"
msgstr "Dit bord verwijderen"
msgid "Mention (ex: Sauf riverains)"
msgstr "Vermelding (bijv. Uitgezonderd aangelanden)"
msgid "Propreté :"
msgstr "Netheid:"
msgid "Propre"
msgstr "Schoon"
msgid "Sale"
msgstr "Vuil"
msgid "Végétation"
msgstr "Vegetatie"
msgid "Dégradé"
msgstr "Beschadigd"
msgid "Coordonnées GPS manquantes."
msgstr "Ontbrekende gps-coördinaten."
msgid "Veuillez ajouter au moins un panneau."
msgstr "Voeg minimaal één bord toe."
msgid "Panneaux créés avec succès !"
msgstr "Borden succesvol aangemaakt!"
msgid "Erreur : "
msgstr "Fout: "
msgid "Impossible de créer les panneaux."
msgstr "Kan de borden niet aanmaken."
msgid "Erreur lors de la création : "
msgstr "Fout bij aanmaken: "
msgid "Créer et Enregistrer"
msgstr "Aanmaken en opslaan"
msgid "Inspection enregistrée !"
msgstr "Inspectie opgeslagen!"
msgid "Intervention"
msgstr "Interventie"
msgid "créée."
msgstr "aangemaakt."
msgid "Erreur lors de la validation."
msgstr "Fout bij validatie."
msgid "Erreur réseau."
msgstr "Netwerkfout."

View file

@ -0,0 +1,210 @@
"""
Management command: create_structure_folders
===========================================
Crée les répertoires par défaut de gestion documentaire pour les structures (ouvrages d'art)
qui n'en possèdent pas encore (ex: stations et tunnels de métro récemment importés).
Pour chaque ouvrage d'art traité :
1. Crée le dossier racine rattaché à l'ouvrage via DocumentFolderAttachment (is_default=True) :
- Nom : "<code_ouvrage> - <nom_ouvrage>" (ex: "MS001 - Station Albert")
- Slug : "<code_minuscule>" (ex: "ms001")
- Dossier parent : "Structures" (slug: "assets-structures")
- Thématique : "structures"
- Visibilité : "internal"
2. Crée l'arborescence standard de sous-dossiers :
- Civil engineering
├── Inspection reports
└── Exceptional transport
- Electromechanics
└── Preventive maintenance
Usage :
python manage.py create_structure_folders [--dry-run] [--type TYPE] [--code CODE]
"""
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.text import slugify
from assets.models.structures import Structure
from common.models import Thematic
from documents.models import DocumentFolder, DocumentFolderAttachment, VisibilityScope
User = get_user_model()
def ensure_structure_folders(structure, creator_user=None, dry_run=False):
"""
Crée ou s'assure de l'existence du dossier racine et des sous-dossiers
standards pour un ouvrage d'art (Structure).
Idempotent : ne recrée pas ce qui existe déjà.
"""
structure_ct = ContentType.objects.get_for_model(Structure)
structures_thematic = Thematic.objects.filter(code='structures').first()
structures_parent_folder = DocumentFolder.objects.filter(slug='assets-structures').first()
root_slug = slugify(structure.code)
root_folder = DocumentFolder.objects.filter(slug=root_slug).first()
root_attachment = DocumentFolderAttachment.objects.filter(
content_type=structure_ct,
object_id=structure.pk,
is_default=True
).select_related('folder').first()
if not root_folder and root_attachment:
root_folder = root_attachment.folder
root_name = f"{structure.code} - {structure.get_name()}"
root_desc = f"Dossier pour la structure {structure.code} - {structure.get_name()}"
created_root = False
if not root_folder:
if not dry_run:
root_folder = DocumentFolder.objects.create(
name=root_name,
slug=root_slug,
description=root_desc,
visibility=VisibilityScope.INTERNAL,
is_predefined=True,
application_label='structures',
created_by=creator_user
)
if structures_thematic:
root_folder.thematics.add(structures_thematic)
if structures_parent_folder:
root_folder.parent_folders.add(structures_parent_folder)
created_root = True
if not dry_run and root_folder and not root_attachment:
DocumentFolderAttachment.objects.get_or_create(
content_type=structure_ct,
object_id=structure.pk,
folder=root_folder,
defaults={'is_default': True}
)
subfolders_created = 0
def get_or_create_subfolder(parent, name, sub_suffix):
nonlocal subfolders_created
base_slug = f"{root_slug}-{sub_suffix}"[:150]
sub = DocumentFolder.objects.filter(slug=base_slug).first()
if not sub and parent and getattr(parent, 'pk', None):
sub = parent.child_folders.filter(name=name).first()
if not sub:
if not dry_run:
sub = DocumentFolder.objects.create(
name=name,
slug=base_slug,
visibility=VisibilityScope.INTERNAL,
is_predefined=False,
application_label='structures',
created_by=creator_user
)
if structures_thematic:
sub.thematics.add(structures_thematic)
if parent and getattr(parent, 'pk', None):
sub.parent_folders.add(parent)
subfolders_created += 1
return sub, True
return sub, False
# 1. Civil engineering & sous-dossiers
civil_folder, _ = get_or_create_subfolder(root_folder, "Civil engineering", "civil-engineering")
get_or_create_subfolder(civil_folder, "Inspection reports", "civil-engineering-inspection-reports")
get_or_create_subfolder(civil_folder, "Exceptional transport", "civil-engineering-exceptional-transport")
# 2. Electromechanics & sous-dossiers
em_folder, _ = get_or_create_subfolder(root_folder, "Electromechanics", "electromechanics")
get_or_create_subfolder(em_folder, "Preventive maintenance", "electromechanics-preventive-maintenance")
return {
'root_folder': root_folder,
'created_root': created_root,
'subfolders_created': subfolders_created
}
class Command(BaseCommand):
help = "Crée les répertoires par défaut de gestion documentaire pour les structures."
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help="Simule l'exécution sans effectuer d'écritures en base de données."
)
parser.add_argument(
'--type',
dest='structure_type',
help="Filtre par type de structure (ex: metro_station,metro_tunnel)."
)
parser.add_argument(
'--code',
dest='structure_code',
help="Cible un ouvrage spécifique via son code (ex: MS001)."
)
parser.add_argument(
'--user',
dest='username',
help="Nom d'utilisateur créateur des dossiers (par défaut kdeterme ou superuser)."
)
def handle(self, *args, **options):
dry_run = options['dry_run']
structure_type = options.get('structure_type')
structure_code = options.get('structure_code')
username = options.get('username')
if dry_run:
self.stdout.write(self.style.WARNING("=== Mode simulation (--dry-run) activé. Aucune modification en base. ==="))
creator_user = None
if username:
creator_user = User.objects.filter(username=username).first()
if not creator_user:
creator_user = User.objects.filter(username="kdeterme").first() or User.objects.filter(is_superuser=True).first()
qs = Structure.objects.all().order_by('code')
if structure_code:
qs = qs.filter(code__iexact=structure_code)
elif structure_type:
types = [t.strip() for t in structure_type.split(',')]
qs = qs.filter(structure_type__in=types)
structure_ct = ContentType.objects.get_for_model(Structure)
existing_attached_ids = set(
DocumentFolderAttachment.objects.filter(
content_type=structure_ct,
is_default=True
).values_list('object_id', flat=True)
)
missing_structures = [s for s in qs if s.pk not in existing_attached_ids]
self.stdout.write(f"Structures analysées : {qs.count()} | Sans répertoire par défaut : {len(missing_structures)}")
total_roots = 0
total_subfolders = 0
target_structures = missing_structures if not structure_code else list(qs)
for structure in target_structures:
res = ensure_structure_folders(structure, creator_user=creator_user, dry_run=dry_run)
if res['created_root']:
total_roots += 1
total_subfolders += res['subfolders_created']
self.stdout.write(f" ✓ [{structure.code}] {structure.get_name()} -> Dossier racine et arborescence configurés")
status_label = "[Simulation] Répertoires qui seraient créés" if dry_run else "Répertoires créés avec succès"
self.stdout.write(self.style.SUCCESS(
f"\n=== {status_label} ===\n"
f" - Dossiers racines créés : {total_roots}\n"
f" - Sous-dossiers créés : {total_subfolders}\n"
f" - Total dossiers : {total_roots + total_subfolders}"
))

View file

@ -0,0 +1,278 @@
import json
from pathlib import Path
from django.core.management.base import BaseCommand
from django.contrib.gis.geos import Point, Polygon, MultiPolygon
from django.db import transaction
from shapely.geometry import shape, Point as ShpPoint
from common.models import Thematic, Municipality
from assets.models import AssetCategory
from assets.models.sign import SignStreet
from assets.models.parking import ParkingLocation, ParkingSpot
class Command(BaseCommand):
help = "Import parking areas, bicycle parking stands and PMR/VE spots for Court-Saint-Étienne"
def handle(self, *args, **options):
self.stdout.write("=== Importing Parking & Bicycle Spots for Court-Saint-Étienne ===")
parking_thematic = Thematic.objects.filter(code='parking').first()
parking_cat = AssetCategory.objects.filter(code='parking_spot').first()
bike_cat = AssetCategory.objects.filter(code='bike_parking_spot').first()
cse = Municipality.objects.get(niscode='25014')
cse_geom_wgs84 = cse.geom.clone()
cse_geom_wgs84.transform(4326)
cse_poly = shape(json.loads(cse_geom_wgs84.geojson))
streets = list(SignStreet.objects.filter(geom__isnull=False))
if not streets:
self.stderr.write("No SignStreet found in database!")
return
osm_json_path = Path("/tmp/osm_cse_sign_parking.json")
if not osm_json_path.exists():
self.stderr.write(f"OSM JSON not found: {osm_json_path}")
return
with open(osm_json_path, 'r', encoding='utf-8') as f:
osm_data = json.load(f)
elements = osm_data.get('elements', [])
# Filter strictly inside CSE
in_cse = []
for el in elements:
lat = el.get('lat') or (el.get('center') and el['center'].get('lat'))
lon = el.get('lon') or (el.get('center') and el['center'].get('lon'))
if lat is not None and lon is not None:
p = ShpPoint(lon, lat)
if cse_poly.contains(p):
el['_pt_wgs84'] = Point(lon, lat, srid=4326)
in_cse.append(el)
self.stdout.write(f"Elements strictly in CSE: {len(in_cse)}")
# ─── 1. ParkingLocation (Zones de stationnement) ───────────────────
self.stdout.write("--- Step 1: ParkingLocation (Aires/Parcs) ---")
parking_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'parking']
self.stdout.write(f"Parking lots in CSE: {len(parking_elements)}")
# Existing locations
existing_loc_codes = set(ParkingLocation.objects.values_list('code', flat=True))
max_loc_num = 0
for c in existing_loc_codes:
if c and c.startswith('CSE-PKG-'):
try:
num = int(c.split('-')[-1])
if num > max_loc_num:
max_loc_num = num
except ValueError:
pass
new_locations = []
for p in parking_elements:
tags = p.get('tags', {})
pt_4326 = p['_pt_wgs84']
pt_3812 = pt_4326.transform(3812, clone=True)
closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812))
# Determine Name
osm_name = tags.get('name')
if osm_name:
name_fr = osm_name.strip()
else:
name_fr = f"Parking {closest_street.name_fr or 'public'}"
# Capacity
try:
cap = int(tags.get('capacity', 15))
except (ValueError, TypeError):
cap = 15
# Size proportional to capacity
size = max(16.0, min(65.0, (cap or 15) * 1.8))
half = size / 2.0
cx, cy = pt_3812.x, pt_3812.y
poly = Polygon([
(cx - half, cy - half),
(cx + half, cy - half),
(cx + half, cy + half),
(cx - half, cy + half),
(cx - half, cy - half),
], srid=3812)
mpoly = MultiPolygon([poly])
access = tags.get('access', 'public')
fee = tags.get('fee', 'non payant')
parking_type = tags.get('parking', 'surface')
desc = f"Aire de stationnement ({parking_type}). Accès: {access}. Tarification: {fee}. Capacité estimée: {cap} places."
max_loc_num += 1
code = f"CSE-PKG-{max_loc_num:04d}"
loc = ParkingLocation(
code=code,
name_fr=name_fr,
geom=mpoly,
description=desc,
address=f"{closest_street.name_fr or 'Court-Saint-Étienne'}, 1490 Court-Saint-Étienne",
)
# Sync
loc.sync_geom_and_geojson()
new_locations.append(loc)
with transaction.atomic():
ParkingLocation.objects.bulk_create(new_locations)
self.stdout.write(self.style.SUCCESS(f"Created {len(new_locations)} ParkingLocations. Total in DB: {ParkingLocation.objects.count()}"))
# Re-fetch all locations with spatial index
all_locations = list(ParkingLocation.objects.filter(geom__isnull=False))
# ─── 2. ParkingSpot (Vélos & Emplacements individuels) ────────────
self.stdout.write("--- Step 2: ParkingSpot (Vélos, PMR, VE) ---")
bike_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'bicycle_parking']
space_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'parking_space']
self.stdout.write(f"Bicycle parkings in CSE: {len(bike_elements)}")
self.stdout.write(f"Parking spaces in CSE: {len(space_elements)}")
max_spot_num = 0
for sc in ParkingSpot.objects.values_list('code', flat=True):
if sc:
try:
num = int(sc.split('-')[-1])
if num > max_spot_num:
max_spot_num = num
except ValueError:
pass
new_spots = []
# A. Bicycle parkings
for b in bike_elements:
tags = b.get('tags', {})
pt_4326 = b['_pt_wgs84']
pt_3812 = pt_4326.transform(3812, clone=True)
closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812))
closest_loc = min(all_locations, key=lambda l: l.geom.distance(pt_3812)) if all_locations else None
# Capacity & stands
try:
cap = int(tags.get('capacity', 6))
except (ValueError, TypeError):
cap = 6
btype_osm = (tags.get('bicycle_parking') or '').lower()
if 'box' in btype_osm or 'shed' in btype_osm or 'secure' in btype_osm:
stand_type = 'box'
stands_label = "Box / Abri sécurisé"
elif 'rack' in btype_osm or 'wall' in btype_osm:
stand_type = 'rack'
stands_label = "Râteau / Pince-roue"
else:
stand_type = 'normal'
stands_label = "Arceaux U standard"
# 3m x 2m polygon in 3812
cx, cy = pt_3812.x, pt_3812.y
poly = Polygon([
(cx - 1.5, cy - 1.0),
(cx + 1.5, cy - 1.0),
(cx + 1.5, cy + 1.0),
(cx - 1.5, cy + 1.0),
(cx - 1.5, cy - 1.0),
], srid=3812)
max_spot_num += 1
code = f"CSE-PKV-{max_spot_num:04d}"
osm_name = tags.get('name')
name_fr = osm_name if osm_name else f"Stationnement vélos ({stands_label}) - {closest_street.name_fr or 'Rue'}"
spot = ParkingSpot(
code=code,
name_fr=name_fr,
spot_type='bicycle',
stand_type=stand_type,
capacity=cap,
standard_stand_count=max(1, cap // 2),
geom=poly,
location=closest_loc,
street=closest_street,
category=bike_cat,
is_compliant=True,
)
spot.sync_geom_and_geojson()
new_spots.append(spot)
# B. Parking Spaces (PMR, EV charging, Car)
for s in space_elements:
tags = s.get('tags', {})
pt_4326 = s['_pt_wgs84']
pt_3812 = pt_4326.transform(3812, clone=True)
closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812))
closest_loc = min(all_locations, key=lambda l: l.geom.distance(pt_3812)) if all_locations else None
# Check PMR vs EV vs standard
is_pmr = (
tags.get('capacity:disabled') in ('1', 'yes') or
tags.get('disabled') in ('yes', 'designated') or
tags.get('access') == 'disabled' or
'pmr' in str(tags).lower() or
'disabled' in str(tags).lower()
)
is_ev = (
'charging' in str(tags).lower() or
tags.get('socket:type2') or
tags.get('amenity') == 'charging_station'
)
if is_pmr:
spot_type = 'pmr'
spot_label = "Emplacement PMR"
elif is_ev:
spot_type = 'ev_charging'
spot_label = "Borne recharge VE"
else:
spot_type = 'car_parking'
spot_label = "Emplacement automobile"
# 2.5m x 5m polygon
cx, cy = pt_3812.x, pt_3812.y
poly = Polygon([
(cx - 1.25, cy - 2.5),
(cx + 1.25, cy - 2.5),
(cx + 1.25, cy + 2.5),
(cx - 1.25, cy + 2.5),
(cx - 1.25, cy - 2.5),
], srid=3812)
max_spot_num += 1
code = f"CSE-PKS-{max_spot_num:04d}"
name_fr = f"{spot_label} - {closest_street.name_fr or 'Rue'}"
spot = ParkingSpot(
code=code,
name_fr=name_fr,
spot_type=spot_type,
capacity=1,
geom=poly,
location=closest_loc,
street=closest_street,
category=parking_cat,
is_compliant=True,
)
spot.sync_geom_and_geojson()
new_spots.append(spot)
with transaction.atomic():
ParkingSpot.objects.bulk_create(new_spots)
self.stdout.write(self.style.SUCCESS(f"Created {len(new_spots)} ParkingSpots (total: {ParkingSpot.objects.count()})."))

View file

@ -0,0 +1,293 @@
import json
import os
import shutil
from pathlib import Path
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.gis.geos import Point, GEOSGeometry
from django.db import transaction
from common.models import Thematic
from assets.models import AssetCategory
from assets.models.sign import SignStreet, SignPole, SignPanel, SignPanelType
BELGIAN_PANEL_TYPES = [
# Priorité
('B1', 'Cédez le passage', 'Voorrang verlenen', 'B/B1.svg'),
('B5', 'Arrêt obligatoire (Stop)', 'Stop', 'B/B5.svg'),
('B9', 'Route prioritaire', 'Voorrangsweg', 'B/B9.svg'),
('B15', 'Fin de route prioritaire', 'Einde voorrangsweg', 'B/B15.svg'),
# Danger
('A1a', 'Virage dangereux à droite', 'Gevaarlijke bocht naar rechts', 'A/A1A.svg'),
('A1b', 'Virage dangereux à gauche', 'Gevaarlijke bocht naar links', 'A/A1B.svg'),
('A7a', 'Passage pour piétons', 'Voetgangersoversteekplaats', 'A/A7A.svg'),
('A14', 'Présence d\'enfants', 'Kinderen', 'A/A14.svg'),
('A23', 'Travaux', 'Werken', 'A/A23.svg'),
('A25', 'Ralentisseur de trafic', 'Verkeersdrempel', 'A/A25.svg'),
# Interdiction
('C1', 'Sens interdit', 'Verboden richting', 'C/C1.svg'),
('C3', 'Accès interdit dans les deux sens', 'Verboden toegang in beide richtingen', 'C/C3.svg'),
('C11', 'Accès interdit aux cycles', 'Verboden toegang voor fietsers', 'C/C11.svg'),
('C43', 'Vitesse maximale limitée (50/30 km/h)', 'Maximumsnelheid', 'C/C43.svg'),
('C45', 'Fin de limitation de vitesse', 'Einde snelheidsbeperking', 'C/C45.svg'),
# Obligation
('D7', 'Piste cyclable obligatoire', 'Verplicht fietspad', 'D/D7.svg'),
('D9', 'Partage piétons / cyclistes', 'Gedeeld voetpad en fietspad', 'D/D9.svg'),
# Stationnement
('E1', 'Stationnement interdit', 'Parkeerverbod', 'E/E1.svg'),
('E3', 'Arrêt et stationnement interdits', 'Stilstand- en parkeerverbod', 'E/E3.svg'),
('E9a', 'Stationnement autorisé', 'Parkeren toegelaten', 'E/E9A.svg'),
('E9b', 'Stationnement réservé aux PMR', 'Parkeren voor personen met een handicap', 'E/E9B.svg'),
('E9c', 'Stationnement camionnettes / camions', 'Parkeren voor vrachtwagens', 'E/E9C.svg'),
('E9h', 'Stationnement véhicules électriques (recharge)', 'Parkeren voor elektrische voertuigen', 'E/E9H.svg'),
# Indications
('F4a', 'Début de zone 30', 'Begin van een zone 30', 'F/F4A.svg'),
('F4b', 'Fin de zone 30', 'Einde van een zone 30', 'F/F4B.svg'),
('F12a', 'Zone résidentielle ou de rencontre', 'Woonerf of erf', 'F/F12A.svg'),
('F19', 'Voie publique à sens unique', 'Eenrichtingsverkeer', 'F/F19.svg'),
('F45', 'Impasse', 'Doodlopende weg', 'F/F45.svg'),
('F49', 'Passage pour piétons', 'Voetgangersoversteekplaats', 'F/F49.svg'),
('F99a', 'Chemin réservé piétons / cyclistes', 'Weg voorbehouden voor voetgangers en fietsers', 'F/F99A.svg'),
('F101a', 'Fin de chemin réservé', 'Einde weg voorbehouden', 'F/F101A.svg'),
]
class Command(BaseCommand):
help = "Import signage (PICC poles and road panels) for Court-Saint-Étienne"
def handle(self, *args, **options):
self.stdout.write("=== Importing Signage for Court-Saint-Étienne ===")
# 1. SignPanelType catalog
self.stdout.write("--- Step 1: SignPanelType catalog ---")
media_target_dir = Path(settings.MEDIA_ROOT) / "sign" / "signpanels_types"
media_target_dir.mkdir(parents=True, exist_ok=True)
static_road_signs = Path(settings.BASE_DIR) / "assets" / "static" / "assets" / "road_signs"
if not static_road_signs.exists():
# Fallback check
static_road_signs = Path("/app/static/assets/road_signs")
created_types = 0
for code, name_fr, name_nl, rel_svg in BELGIAN_PANEL_TYPES:
pt, created = SignPanelType.objects.get_or_create(
code=code,
defaults={
'name_fr': name_fr,
'name_nl': name_nl,
'default_width': 50.0,
'default_height': 50.0,
}
)
# Copy SVG icon if exists
svg_source = static_road_signs / rel_svg
if svg_source.exists():
dst_name = f"{code.lower()}.svg"
dst_path = media_target_dir / dst_name
if not dst_path.exists():
shutil.copyfile(svg_source, dst_path)
pt.sgv_picture = f"sign/signpanels_types/{dst_name}"
pt.save(update_fields=['sgv_picture'])
if created:
created_types += 1
self.stdout.write(f"SignPanelTypes ensured: {SignPanelType.objects.count()} (created {created_types})")
# 2. Import PICC Poles
self.stdout.write("--- Step 2: PICC Poles ---")
sign_thematic = Thematic.objects.filter(code='sign').first()
pole_category = AssetCategory.objects.filter(code='SI_POLE').first()
streets = list(SignStreet.objects.filter(geom__isnull=False))
if not streets:
self.stderr.write("No SignStreet found in database!")
return
# Preload existing poles to avoid duplicates (< 1.5m)
existing_poles = list(SignPole.objects.all().values('id', 'code', 'geom'))
self.stdout.write(f"Existing poles in DB: {len(existing_poles)}")
# Find highest code number
max_num = 0
for ep in existing_poles:
c = ep.get('code') or ''
if c.startswith('CSE-SGP-'):
try:
num = int(c.split('-')[-1])
if num > max_num:
max_num = num
except ValueError:
pass
geojson_path = Path("/tmp/cse_picc_poles.geojson")
if not geojson_path.exists():
self.stderr.write(f"GeoJSON not found: {geojson_path}")
return
with open(geojson_path, 'r', encoding='utf-8') as f:
data = json.load(f)
features = data.get('features', [])
self.stdout.write(f"PICC poles in GeoJSON: {len(features)}")
# Street poles counters for names
street_counters = {}
for s in streets:
street_counters[s.id] = SignPole.objects.filter(street=s).count()
new_poles = []
skipped_existing = 0
for feat in features:
coords = feat['geometry']['coordinates']
x, y = coords[0], coords[1]
pt = Point(x, y, srid=3812)
# Check if existing pole is close (< 1.5m)
is_duplicate = False
for ep in existing_poles:
eg = ep.get('geom')
if eg and eg.distance(pt) < 1.5:
is_duplicate = True
break
if is_duplicate:
skipped_existing += 1
continue
# Find closest street
closest_street = min(streets, key=lambda st: st.geom.distance(pt))
street_counters[closest_street.id] = street_counters.get(closest_street.id, 0) + 1
street_num = street_counters[closest_street.id]
max_num += 1
code = f"CSE-SGP-{max_num:04d}"
name = f"Poteau signalisation - {closest_street.name_fr or 'Rue'} #{street_num}"
pole = SignPole(
code=code,
name_fr=name,
geom=pt,
street=closest_street,
category=pole_category,
status='active',
orientation=90
)
# Sync lon, lat, geojson
pole.sync_geom_and_geojson()
new_poles.append(pole)
# Add to local cache for duplicate checking
existing_poles.append({'id': None, 'code': code, 'geom': pt})
if new_poles:
with transaction.atomic():
SignPole.objects.bulk_create(new_poles, batch_size=500)
self.stdout.write(self.style.SUCCESS(f"Created {len(new_poles)} new SignPoles (skipped {skipped_existing} existing). Total in DB: {SignPole.objects.count()}"))
else:
self.stdout.write(f"No new poles created (skipped {skipped_existing} duplicates).")
# 3. Import Panels from OSM
self.stdout.write("--- Step 3: SignPanels from OSM ---")
panel_category = AssetCategory.objects.filter(code='SI_PANEL').first()
osm_json_path = Path("/tmp/osm_cse_sign_parking.json")
if not osm_json_path.exists():
self.stderr.write(f"OSM JSON not found: {osm_json_path}")
return
with open(osm_json_path, 'r', encoding='utf-8') as f:
osm_data = json.load(f)
osm_elements = osm_data.get('elements', [])
osm_signs = [
e for e in osm_elements
if 'traffic_sign' in e.get('tags', {}) or e.get('tags', {}).get('highway') in ('give_way', 'stop')
]
self.stdout.write(f"OSM signs found in dataset: {len(osm_signs)}")
all_poles = list(SignPole.objects.select_related('street').all())
type_b1 = SignPanelType.objects.filter(code='B1').first()
type_b5 = SignPanelType.objects.filter(code='B5').first()
type_f99a = SignPanelType.objects.filter(code='F99a').first()
max_panel_num = 0
for sp in SignPanel.objects.all().values_list('code', flat=True):
if sp and sp.startswith('CSE-PAN-'):
try:
num = int(sp.split('-')[-1])
if num > max_panel_num:
max_panel_num = num
except ValueError:
pass
panels_created = 0
for s in osm_signs:
tags = s.get('tags', {})
lat = s.get('lat') or (s.get('center') and s['center'].get('lat'))
lon = s.get('lon') or (s.get('center') and s['center'].get('lon'))
if lat is None or lon is None:
continue
pt_4326 = Point(lon, lat, srid=4326)
pt_3812 = pt_4326.transform(3812, clone=True)
# Determine SignPanelType
sig = tags.get('traffic_sign') or ''
hw = tags.get('highway') or ''
panel_type = None
if 'B1' in sig or hw == 'give_way':
panel_type = type_b1
elif 'B5' in sig or hw == 'stop':
panel_type = type_b5
elif 'F99a' in sig:
panel_type = type_f99a
else:
# Try to extract code from string like "BE:B1", "BE:C43"
clean_code = sig.replace('BE:', '').split(';')[0].strip()
panel_type = SignPanelType.objects.filter(code__iexact=clean_code).first()
if not panel_type:
panel_type = type_b1 # default fallback
# Find nearest pole within 30 meters
nearest_pole = None
min_dist = float('inf')
for p in all_poles:
d = p.geom.distance(pt_3812)
if d < min_dist:
min_dist = d
nearest_pole = p
if not nearest_pole or min_dist > 35.0:
# If no pole close enough, attach to closest pole anyway or skip
if not nearest_pole:
continue
max_panel_num += 1
pcode = f"CSE-PAN-{max_panel_num:04d}"
pname = f"Panneau {panel_type.code} - {nearest_pole.street.name_fr if nearest_pole.street else 'Rue'}"
panel = SignPanel(
code=pcode,
name_fr=pname,
pole=nearest_pole,
street=nearest_pole.street,
signpanel_type=panel_type,
category=panel_category,
status='active',
orientation=nearest_pole.orientation or 90,
support_type='pole',
geom=nearest_pole.geom,
)
panel.sync_geom_and_geojson()
panel.save()
panels_created += 1
self.stdout.write(self.style.SUCCESS(f"Created {panels_created} SignPanels attached to poles. Total panels: {SignPanel.objects.count()}"))

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

@ -9,7 +9,7 @@ def create_parking_thematic(apps, schema_editor):
thematic, _created = Thematic.objects.get_or_create(
code='parking',
defaults={
'name_fr': 'Stationnement & Mobilité',
'name_fr': 'Stationnement',
'name_nl': 'Parkeren & Mobiliteit',
'description': 'Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)',
'icon': 'bi-p-square',

View file

@ -13,7 +13,7 @@ def create_bike_parking_category(apps, schema_editor):
thematic, _ = Thematic.objects.get_or_create(
code='parking',
defaults={
'name_fr': 'Stationnement & Mobilité',
'name_fr': 'Stationnement',
'name_nl': 'Parkeren & Mobiliteit',
'description': 'Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)',
'icon': 'bi-p-square',

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-09-21 11:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0026_add_naturetree_specific_fields'),
]
operations = [
migrations.AlterField(
model_name='structure',
name='structure_type',
field=models.CharField(blank=True, choices=[('tunnel', 'Tunnel'), ('bridge', 'Pont'), ('viaduct', 'Viaduc'), ('overpass', 'Passerelle'), ('underpass', 'Passage souterrain'), ('gallery', 'Galerie technique'), ('atypical', 'Ouvrage atypique'), ('metro_station', 'Station de métro'), ('metro_tunnel', 'Tunnel de métro')], max_length=200, null=True),
),
]

View file

@ -17,13 +17,15 @@ private_storage = PrivateMediaStorage()
STRUCTURE_TYPE_CHOICES = (
('tunnel', 'Tunnnel'),
('bridge', 'Pont'),
('viaduct', 'Viaduc'),
('overpass', 'Passerelle'),
('underpass', 'Passage souterrain'),
('gallery', 'Galerie technique'),
('atypical', 'Ouvrage atypique'),
('tunnel', _('Tunnel')),
('bridge', _('Pont')),
('viaduct', _('Viaduc')),
('overpass', _('Passerelle')),
('underpass', _('Passage souterrain')),
('gallery', _('Galerie technique')),
('atypical', _('Ouvrage atypique')),
('metro_station', _('Station de métro')),
('metro_tunnel', _('Tunnel de métro')),
)

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

@ -244,6 +244,15 @@
glyphCodepoint: 'f5e1',
mapImage: 'icon-naturebench',
},
natureplanter: {
thematic: 'nature',
color: '#d97706',
iconUrl: '/static/assets/nature_planter_icon.svg',
iconSize: 38,
iconClass: 'bi-flower2',
glyphCodepoint: 'f3ce',
mapImage: 'icon-natureplanter',
},
naturerwiasset: {
thematic: 'nature',
color: '#0277bd',
@ -345,11 +354,21 @@
},
roadfurniture: {
thematic: 'roads',
color: '#607d8b',
iconUrl: null,
iconClass: 'bi-map-fill',
glyphCodepoint: 'f47e',
mapImage: 'icon-roads',
color: '#e67700',
iconUrl: '/static/assets/road_speedbump_icon.svg?v=20260920',
iconSize: 44,
iconClass: 'bi-triangle-half',
glyphCodepoint: 'f66d',
mapImage: 'icon-speedbump',
},
streetfurniture: {
thematic: 'roads',
color: '#e67700',
iconUrl: '/static/assets/road_speedbump_icon.svg?v=20260920',
iconSize: 44,
iconClass: 'bi-triangle-half',
glyphCodepoint: 'f66d',
mapImage: 'icon-speedbump',
},
// Clean
@ -363,11 +382,12 @@
},
cleanglasscontainer: {
thematic: 'clean',
color: '#5e5e5e',
iconUrl: null,
iconClass: 'bi-trash3-fill',
color: '#16a34a',
iconUrl: '/static/assets/clean_glass_container_icon.svg?v=20260920',
iconSize: 46,
iconClass: 'bi-recycle',
glyphCodepoint: 'f78a',
mapImage: 'icon-clean',
mapImage: 'icon-cleanglasscontainer',
},
// Artworks
@ -531,6 +551,14 @@
}
// 3. Specific asset_type override
if (assetType === 'naturebench') {
const isPlanter = (item.asset_sub_type === 'planter') ||
['Bac à fleurs / Jardinière', 'Bloembak', 'Jardinière', 'Bac à fleurs', 'Bac à plantes'].includes(item.model) ||
(typeof item.code === 'string' && item.code.startsWith('CSE-BAC'));
if (isPlanter && ASSET_TYPE_OVERRIDES.natureplanter) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.natureplanter);
}
}
if (assetType && ASSET_TYPE_OVERRIDES[assetType]) {
return Object.assign({}, ASSET_TYPE_OVERRIDES[assetType]);
}
@ -602,6 +630,7 @@
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
naturebench: injected.naturebench || ASSET_TYPE_OVERRIDES.naturebench.iconUrl,
natureplanter: injected.natureplanter || (ASSET_TYPE_OVERRIDES.natureplanter && ASSET_TYPE_OVERRIDES.natureplanter.iconUrl),
artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl,
artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl,
};
@ -622,11 +651,13 @@
{ name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' },
{ name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' },
{ name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' },
{ name: 'icon-natureplanter', codepoint: 'f3ce', color: '#d97706' },
{ name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' },
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' },
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' },
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' },
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' },
{ name: 'icon-cleanglasscontainer', codepoint: 'f78a', color: '#16a34a' },
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' },
{ name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' },
{ name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' },
@ -638,6 +669,7 @@
{ name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' },
{ name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' },
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' },
{ name: 'icon-speedbump', codepoint: 'f66d', color: '#e67700' },
{ name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' },
{ name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' },
{ name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' },
@ -695,11 +727,26 @@
['==', ['get', 'asset_type'], 'trafficlightnetworkhardware'], 'icon-trafficlightsequipment',
['==', ['get', 'asset_type'], 'trafficlightvoltagehardware'], 'icon-trafficlightsequipment',
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], 'icon-publiclightingelectricalcabinet',
['==', ['get', 'asset_type'], 'cleanglasscontainer'], 'icon-cleanglasscontainer',
['==', ['get', 'category'], 'Bulle'], 'icon-cleanglasscontainer',
['==', ['get', 'category'], 'Bulle à verre'], 'icon-cleanglasscontainer',
['==', ['get', 'asset_type'], 'artworkfountain'], 'icon-artworkfountain',
['==', ['get', 'asset_type'], 'artworkmonument'], 'icon-artworkmonument',
['any',
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
], 'icon-natureplanter',
['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench',
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump',
['==', ['get', 'asset_type'], 'streetfurniture'], 'icon-speedbump',
['==', ['get', 'asset_type'], 'roadfurniture'], 'icon-speedbump',
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone',
@ -772,12 +819,24 @@
['coalesce', ['get', 'color'], '#2e7d32'],
['==', ['get', 'thematic'], 'parking'],
getParkingColorExpr(),
['==', ['get', 'thematic'], 'roads'],
[
'match',
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#e03131',
['2', '2 - Mauvais'], '#f76707',
['3', '3 - Bon'], '#fcc419',
['4', '4 - Très bon'], '#74b816',
['5', '5 - Excellent'], '#2f9e44',
'#4a4a4a'
],
getThematicColorExpr()
],
'fill-opacity': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8,
['==', ['get', 'thematic'], 'parking'], 0.38,
['==', ['get', 'thematic'], 'roads'], 0.28,
0.65
],
'fill-antialias': false,
@ -811,14 +870,30 @@
['coalesce', ['get', 'color'], '#1b5e20'],
['==', ['get', 'thematic'], 'parking'],
getParkingColorExpr(),
['==', ['get', 'thematic'], 'roads'],
[
'match',
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#c92a2a',
['2', '2 - Mauvais'], '#e8590c',
['3', '3 - Bon'], '#f59f00',
['4', '4 - Très bon'], '#66a80f',
['5', '5 - Excellent'], '#2b8a3e',
'#4a4a4a'
],
getThematicColorExpr()
],
'line-width': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4,
['==', ['get', 'thematic'], 'roads'], 1.5,
2
],
'line-opacity': 0.95,
'line-opacity': [
'case',
['==', ['get', 'thematic'], 'roads'], 0.65,
0.95
],
},
minzoom: 8,
};

View file

@ -3,16 +3,16 @@
}
.intervention-btn {
color: var(--brand-color);
color: #0f172a;
margin-right: 4px;
}
.intersection-btn-toggle{
color: var(--brand-color);
color: #0f172a;
}
.separator-top {
border-top: 1px solid var(--brand-color);
border-top: 1px solid #e2e8f0;
margin-top: 2rem; /* optionnel */
padding-top: 2rem; /* optionnel */
}

View file

@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="glassDome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#22c55e"/>
<stop offset="40%" stop-color="#16a34a"/>
<stop offset="100%" stop-color="#15803d"/>
</linearGradient>
<linearGradient id="glassBase" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#14532d"/>
<stop offset="100%" stop-color="#052e16"/>
</linearGradient>
<filter id="glassShadow" x="-15%" y="-15%" width="130%" height="135%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.35"/>
</filter>
</defs>
<!-- Top crane lifting ring (anneau de préhension) -->
<circle cx="32" cy="11" r="5.5" fill="none" stroke="#475569" stroke-width="3" filter="url(#glassShadow)"/>
<rect x="29.5" y="14" width="5" height="4" fill="#334155"/>
<!-- Base rim / skirt -->
<rect x="8" y="51" width="48" height="6" rx="2.5" fill="url(#glassBase)" filter="url(#glassShadow)"/>
<!-- Main Igloo / Bell Dome Body -->
<path d="M 10 51 C 10 27, 19 16, 32 16 C 45 16, 54 27, 54 51 Z"
fill="url(#glassDome)" stroke="#166534" stroke-width="1.5" filter="url(#glassShadow)"/>
<!-- Dome highlight curve -->
<path d="M 14 49 C 14 29, 21 19, 32 19" fill="none" stroke="#86efac" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
<!-- Left bottle drop aperture (orifice bouteilles) -->
<circle cx="21" cy="36" r="6" fill="#0f172a" stroke="#f1f5f9" stroke-width="1.8"/>
<circle cx="21" cy="36" r="4" fill="#020617"/>
<!-- Right bottle drop aperture (orifice bouteilles) -->
<circle cx="43" cy="36" r="6" fill="#0f172a" stroke="#f1f5f9" stroke-width="1.8"/>
<circle cx="43" cy="36" r="4" fill="#020617"/>
<!-- Center white glass bottle emblem -->
<!-- Cap / lip -->
<rect x="30" y="27" width="4" height="1.5" rx="0.5" fill="#ffffff"/>
<!-- Neck -->
<rect x="30.5" y="28.5" width="3" height="5" rx="0.5" fill="#ffffff"/>
<!-- Shoulder & Body -->
<path d="M 30.5 33.5 C 28.5 35.5, 28 38, 28 40 L 28 46 C 28 47.5, 36 47.5, 36 46 L 36 40 C 36 38, 35.5 35.5, 33.5 33.5 Z" fill="#ffffff"/>
<!-- Bottle shine -->
<line x1="30" y1="38" x2="30" y2="44" stroke="#86efac" stroke-width="1" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -89,9 +89,11 @@
if (typeof AddressSearchControl !== 'undefined') {
const autocompleteUrl = window.AUTOCOMPLETE_STREET_URL || '/common/autocomplete-street/';
const i18n = window.INSPECTION_MAP_I18N || {};
const placeholder = i18n.searchAddress || (typeof gettext !== 'undefined' ? gettext('Rechercher une adresse…') : 'Rechercher une adresse…');
const searchCtrl = new AddressSearchControl({
autocompleteUrl: autocompleteUrl,
placeholder: 'Rechercher une adresse...'
placeholder: placeholder
});
map.addControl(searchCtrl, 'top-left');
}
@ -744,8 +746,9 @@
function locateUser() {
if (!navigator.geolocation) return;
const i18n = window.INSPECTION_MAP_I18N || {};
const statusText = document.getElementById('gps-status-text');
if (statusText) statusText.textContent = 'Géolocalisation...';
if (statusText) statusText.textContent = i18n.geolocation || (typeof gettext !== 'undefined' ? gettext('Géolocalisation...') : 'Géolocalisation...');
navigator.geolocation.getCurrentPosition(
function(position) {
@ -766,11 +769,11 @@
map.flyTo({ center: [lng, lat], zoom: 17 });
}
if (statusText) statusText.textContent = 'Position actualisée';
if (statusText) statusText.textContent = i18n.positionUpdated || (typeof gettext !== 'undefined' ? gettext('Position actualisée') : 'Position actualisée');
},
function(err) {
console.warn('GPS error:', err.message);
if (statusText) statusText.textContent = 'GPS indisponible';
if (statusText) statusText.textContent = i18n.gpsUnavailable || (typeof gettext !== 'undefined' ? gettext('GPS indisponible') : 'GPS indisponible');
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 }
);
@ -931,13 +934,17 @@
const STORAGE_HISTORY_PERIOD_KEY = 'loko_insp_history_period';
function getHistoryPeriodLabels() {
const i18n = window.INSPECTION_MAP_I18N || {};
if (i18n.periodLabels) {
return i18n.periodLabels;
}
return {
'disabled': 'Inspections (Désactivé)',
'1m': 'Inspections (1 mois)',
'3m': 'Inspections (3 mois)',
'6m': 'Inspections (6 mois)',
'1y': 'Inspections (1 an)',
'all': 'Inspections (Tout)'
'disabled': (typeof gettext !== 'undefined' ? gettext('Inspections (Désactivé)') : 'Inspections (Désactivé)'),
'1m': (typeof gettext !== 'undefined' ? gettext('Inspections (1 mois)') : 'Inspections (1 mois)'),
'3m': (typeof gettext !== 'undefined' ? gettext('Inspections (3 mois)') : 'Inspections (3 mois)'),
'6m': (typeof gettext !== 'undefined' ? gettext('Inspections (6 mois)') : 'Inspections (6 mois)'),
'1y': (typeof gettext !== 'undefined' ? gettext('Inspections (1 an)') : 'Inspections (1 an)'),
'all': (typeof gettext !== 'undefined' ? gettext('Inspections (Tout)') : 'Inspections (Tout)')
};
}
@ -959,7 +966,7 @@
}
});
if (labelEl) labelEl.textContent = labels[period] || 'Inspections (3 mois)';
if (labelEl) labelEl.textContent = labels[period] || labels['3m'] || (typeof gettext !== 'undefined' ? gettext('Inspections (3 mois)') : 'Inspections (3 mois)');
if (iconEl) {
if (period === 'disabled') {
iconEl.className = 'bi bi-eye-slash text-muted me-1';
@ -1980,9 +1987,9 @@
row.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="badge bg-primary rounded-pill small">#${order} (${labelFr || 'Panneau'})</span>
<span class="badge bg-primary rounded-pill small">#${order} (${labelFr || (window.gettext ? window.gettext('Panneau') : 'Panneau')})</span>
${confidence ? `<span class="badge bg-success-subtle text-success border border-success-subtle extra-small">IA: ${Math.round(confidence * 100)}%</span>` : ''}
<button type="button" class="btn btn-outline-danger btn-xs py-0 px-1.5 btn-remove-panel-row" style="font-size: 0.75rem;" title="Supprimer ce panneau">
<button type="button" class="btn btn-outline-danger btn-xs py-0 px-1.5 btn-remove-panel-row" style="font-size: 0.75rem;" title="${window.gettext ? window.gettext('Supprimer ce panneau') : 'Supprimer ce panneau'}">
<i class="bi bi-trash"></i>
</button>
</div>
@ -1994,23 +2001,23 @@
</div>
<div class="col">
<div class="input-group input-group-sm mb-1">
<span class="input-group-text fw-bold">Code</span>
<span class="input-group-text fw-bold">${window.gettext ? window.gettext('Code') : 'Code'}</span>
<input type="text" class="form-control form-control-sm text-uppercase panel-row-code" value="${upperCode}" placeholder="Ex: C1, D7, B1, M2...">
</div>
<input type="text" class="form-control form-control-sm panel-row-text" value="${text || ''}" placeholder="Mention (ex: Sauf riverains)">
<input type="text" class="form-control form-control-sm panel-row-text" value="${text || ''}" placeholder="${window.gettext ? window.gettext('Mention (ex: Sauf riverains)') : 'Mention (ex: Sauf riverains)'}">
</div>
</div>
<div class="mt-2 pt-1 border-top d-flex align-items-center justify-content-between">
<small class="text-muted extra-small">Propreté :</small>
<small class="text-muted extra-small">${window.gettext ? window.gettext('Propreté :') : 'Propreté :'}</small>
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_clean_${order}_${Date.now()}" value="clean" ${cleanliness === 'clean' ? 'checked' : ''}>
<label class="btn btn-outline-success btn-xs py-0 px-2" for="rc_clean_${order}_${Date.now()}">Propre</label>
<label class="btn btn-outline-success btn-xs py-0 px-2" for="rc_clean_${order}_${Date.now()}">${window.gettext ? window.gettext('Propre') : 'Propre'}</label>
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_dirty_${order}_${Date.now()}" value="dirty" ${cleanliness === 'dirty' ? 'checked' : ''}>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_dirty_${order}_${Date.now()}">Sale</label>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_dirty_${order}_${Date.now()}">${window.gettext ? window.gettext('Sale') : 'Sale'}</label>
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_veg_${order}_${Date.now()}" value="vegetation" ${cleanliness === 'vegetation' ? 'checked' : ''}>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_veg_${order}_${Date.now()}">Végétation</label>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_veg_${order}_${Date.now()}">${window.gettext ? window.gettext('Végétation') : 'Végétation'}</label>
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_vand_${order}_${Date.now()}" value="vandalized" ${cleanliness === 'vandalized' ? 'checked' : ''}>
<label class="btn btn-outline-danger btn-xs py-0 px-2" for="rc_vand_${order}_${Date.now()}">Dégradé</label>
<label class="btn btn-outline-danger btn-xs py-0 px-2" for="rc_vand_${order}_${Date.now()}">${window.gettext ? window.gettext('Dégradé') : 'Dégradé'}</label>
</div>
</div>
`;
@ -2043,13 +2050,13 @@
const lon = document.getElementById('new_sign_lon')?.value;
if (!lat || !lon) {
alert('Coordonnées GPS manquantes.');
alert(window.gettext ? window.gettext('Coordonnées GPS manquantes.') : 'Coordonnées GPS manquantes.');
return;
}
const rows = document.querySelectorAll('.new-sign-panel-row');
if (!rows.length) {
alert('Veuillez ajouter au moins un panneau.');
alert(window.gettext ? window.gettext('Veuillez ajouter au moins un panneau.') : 'Veuillez ajouter au moins un panneau.');
return;
}
@ -2074,7 +2081,8 @@
newSignPhotos.forEach(p => formData.append('photos', p));
btnSubmit.disabled = true;
btnSubmit.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Création en cours...';
const creatingMsg = window.gettext ? window.gettext('Création en cours...') : 'Création en cours...';
btnSubmit.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span> ${creatingMsg}`;
try {
const csrfToken = getCsrfToken();
@ -2095,7 +2103,7 @@
if (data.success) {
modal.hide();
alert(data.message || 'Panneaux créés avec succès !');
alert(data.message || (window.gettext ? window.gettext('Panneaux créés avec succès !') : 'Panneaux créés avec succès !'));
// Basculer automatiquement sur la thématique Signalisation
localStorage.setItem(STORAGE_THEMATIC_KEY, 'sign');
@ -2113,13 +2121,17 @@
loadAssets();
loadInspectionsHistory();
} else {
alert('Erreur : ' + (data.message || 'Impossible de créer les panneaux.'));
const errPrefix = window.gettext ? window.gettext('Erreur : ') : 'Erreur : ';
const defaultErrMsg = window.gettext ? window.gettext('Impossible de créer les panneaux.') : 'Impossible de créer les panneaux.';
alert(errPrefix + (data.message || defaultErrMsg));
}
} catch(err) {
alert('Erreur lors de la création : ' + err.message);
const errCreationMsg = window.gettext ? window.gettext('Erreur lors de la création : ') : 'Erreur lors de la création : ';
alert(errCreationMsg + err.message);
} finally {
btnSubmit.disabled = false;
btnSubmit.innerHTML = '<i class="bi bi-check2-circle fs-6"></i> <span>Créer et Enregistrer</span>';
const createAndSaveMsg = window.gettext ? window.gettext('Créer et Enregistrer') : 'Créer et Enregistrer';
btnSubmit.innerHTML = `<i class="bi bi-check2-circle fs-6"></i> <span>${createAndSaveMsg}</span>`;
}
});
}
@ -2176,19 +2188,22 @@
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
let msg = data.message || 'Inspection enregistrée !';
let msg = data.message || (window.gettext ? window.gettext('Inspection enregistrée !') : 'Inspection enregistrée !');
if (data.created_intervention_code) {
msg += ' Intervention ' + data.created_intervention_code + ' créée.';
const intervLabel = window.gettext ? window.gettext('Intervention') : 'Intervention';
const createdLabel = window.gettext ? window.gettext('créée.') : 'créée.';
msg += ` ${intervLabel} ${data.created_intervention_code} ${createdLabel}`;
}
alert(msg);
loadAssets();
} else {
alertDiv.textContent = data.error || 'Erreur lors de la validation.';
const defaultValError = window.gettext ? window.gettext('Erreur lors de la validation.') : 'Erreur lors de la validation.';
alertDiv.textContent = data.error || defaultValError;
alertDiv.classList.remove('d-none');
}
})
.catch(err => {
alertDiv.textContent = 'Erreur réseau.';
alertDiv.textContent = window.gettext ? window.gettext('Erreur réseau.') : 'Erreur réseau.';
alertDiv.classList.remove('d-none');
})
.finally(() => {

View file

@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="planterWood" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ea580c"/>
<stop offset="100%" stop-color="#9a3412"/>
</linearGradient>
<linearGradient id="leafGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4ade80"/>
<stop offset="100%" stop-color="#15803d"/>
</linearGradient>
<linearGradient id="flowerPink" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fb7185"/>
<stop offset="100%" stop-color="#e11d48"/>
</linearGradient>
</defs>
<!-- Foliage / Leaves -->
<!-- Left leaf -->
<path d="M 32 34 C 24 32, 14 26, 15 17 C 23 17, 28 24, 32 34 Z" fill="url(#leafGrad)"/>
<!-- Right leaf -->
<path d="M 32 34 C 40 32, 50 26, 49 17 C 41 17, 36 24, 32 34 Z" fill="url(#leafGrad)"/>
<!-- Center tall leaves -->
<path d="M 32 34 C 28 26, 26 15, 32 8 C 38 15, 36 26, 32 34 Z" fill="#22c55e"/>
<!-- Side drooping leaves -->
<path d="M 32 35 C 20 35, 10 33, 9 27 C 16 26, 25 29, 32 35 Z" fill="#16a34a"/>
<path d="M 32 35 C 44 35, 54 33, 55 27 C 48 26, 39 29, 32 35 Z" fill="#16a34a"/>
<!-- Flowers -->
<!-- Left flower -->
<circle cx="21" cy="21" r="5" fill="url(#flowerPink)"/>
<circle cx="21" cy="21" r="2" fill="#fef08a"/>
<!-- Right flower -->
<circle cx="43" cy="21" r="5" fill="#f59e0b"/>
<circle cx="43" cy="21" r="2" fill="#fffbeb"/>
<!-- Center top flower -->
<circle cx="32" cy="14" r="6" fill="url(#flowerPink)"/>
<circle cx="32" cy="14" r="2.5" fill="#fef08a"/>
<!-- Planter Box (Bac / Jardinière) -->
<!-- Rim -->
<rect x="11" y="34" width="42" height="5" rx="2" fill="#78350f"/>
<rect x="12" y="35" width="40" height="3" rx="1.5" fill="#ea580c"/>
<!-- Body -->
<polygon points="14,39 50,39 46,55 18,55" fill="url(#planterWood)"/>
<!-- Wooden slats / horizontal grooves -->
<line x1="15" y1="44" x2="49" y2="44" stroke="#78350f" stroke-width="1.2" stroke-linecap="round"/>
<line x1="16.5" y1="49" x2="47.5" y2="49" stroke="#78350f" stroke-width="1.2" stroke-linecap="round"/>
<!-- Corner metal brackets / reinforcements -->
<line x1="14" y1="39" x2="18" y2="55" stroke="#475569" stroke-width="2" stroke-linecap="round"/>
<line x1="50" y1="39" x2="46" y2="55" stroke="#475569" stroke-width="2" stroke-linecap="round"/>
<!-- Feet / Stand -->
<rect x="19" y="55" width="5" height="4" rx="1" fill="#334155"/>
<rect x="40" y="55" width="5" height="4" rx="1" fill="#334155"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="sbBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fb923c"/>
<stop offset="30%" stop-color="#f97316"/>
<stop offset="100%" stop-color="#c2410c"/>
</linearGradient>
<linearGradient id="sbPlateau" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fdba74"/>
<stop offset="100%" stop-color="#fb923c"/>
</linearGradient>
<filter id="sbShadow" x="-15%" y="-15%" width="130%" height="135%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<!-- Drop shadow and dark road border -->
<path d="M 5 48 L 15 17 C 15.5 15.5 17 14.5 18.5 14.5 L 45.5 14.5 C 47 14.5 48.5 15.5 49 17 L 59 48 C 59.5 49.8 58.2 51.5 56.5 51.5 L 7.5 51.5 C 5.8 51.5 4.5 49.8 5 48 Z"
fill="#7c2d12" filter="url(#sbShadow)"/>
<!-- Main Speed Cushion Body (Coussin berlinois) -->
<path d="M 7 47 L 16 18 C 16.5 16.5 17.5 16 19 16 L 45 16 C 46.5 16 47.5 16.5 48 18 L 57 47 C 57.5 48.5 56.5 49.5 55 49.5 L 9 49.5 C 7.5 49.5 6.5 48.5 7 47 Z"
fill="url(#sbBody)"/>
<!-- Lower bevel lip -->
<polygon points="7,47 9.5,50 54.5,50 57,47" fill="#9a3412"/>
<!-- Upper Plateau -->
<polygon points="20,27 22,18 42,18 44,27" fill="url(#sbPlateau)" opacity="0.9"/>
<!-- 3 Reflective White Triangles (Universal Speed Cushion Markings) -->
<polygon points="12,45 20,24 28,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
<polygon points="24,45 32,24 40,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
<polygon points="36,45 44,24 52,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
<!-- Top ridge highlight -->
<line x1="19" y1="16" x2="45" y2="16" stroke="#fed7aa" stroke-width="1.8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -10,37 +10,7 @@ const map = new maplibregl.Map({
antialias: true
});
// Custom Plans Control in Top Right (first, at the top)
class CustomPlansControl {
onAdd(map) {
this._container = document.createElement('div');
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
const button = document.createElement('button');
button.type = 'button';
button.id = 'btn-load-georef-plan';
button.className = 'btn btn-xs btn-light bg-white border shadow-sm d-flex align-items-center gap-1';
button.style.border = 'none';
button.style.padding = '8px 12px';
button.style.height = 'auto';
button.style.width = 'auto';
button.style.fontWeight = 'bold';
button.style.fontSize = '0.75rem';
button.innerHTML = '<i class="bi bi-geo-alt-fill text-primary"></i> Plans';
// Toggle bootstrap modal natively
button.setAttribute('data-bs-toggle', 'modal');
button.setAttribute('data-bs-target', '#georefPlanModal');
this._container.appendChild(button);
return this._container;
}
onRemove() {
this._container.parentNode.removeChild(this._container);
this._map = undefined;
}
}
map.addControl(new CustomPlansControl(), 'top-right');
// Plans & Calques are integrated into LayerSwitcherControl below
// ── Helper to determine where to place the orthophoto layer ──
function getOrthoBeforeLayer(m) {
@ -140,9 +110,10 @@ function reorderIntersectionLayers() {
});
}
// --- Contrôle photo aérienne (LayerSwitcherControl) --- (second, below Plans button)
// --- Contrôle photo aérienne & Plans (LayerSwitcherControl) ---
let layerSwitcher = null;
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layerSwitcher = new LayerSwitcherControl({
layers: [
{
id: 'ortho',
@ -883,7 +854,28 @@ if (georeferencedDocumentsDataEl) {
}
}
const dxfFilesDataEl = document.getElementById('dxfFilesData');
let availableDxfFiles = [];
if (dxfFilesDataEl) {
try {
availableDxfFiles = JSON.parse(dxfFilesDataEl.textContent);
} catch (e) {
console.error("Error parsing dxf files data:", e);
}
}
if (!availableDxfFiles.length && typeof window.dxfFiles !== 'undefined' && Array.isArray(window.dxfFiles)) {
availableDxfFiles = window.dxfFiles;
}
const crossroadPlansConfigEl = document.getElementById('crossroadPlansConfig');
let crossroadPlansConfig = {};
if (crossroadPlansConfigEl) {
try {
crossroadPlansConfig = JSON.parse(crossroadPlansConfigEl.textContent);
} catch (e) {
console.error("Error parsing crossroad plans config:", e);
}
}
function showGeorefImage(doc, active, opacity) {
const srcId = `georef-image-overlay-${doc.id}`;
@ -932,12 +924,14 @@ function showGeorefImage(doc, active, opacity) {
}
// Zoom/fitBounds to document
if (doc.coords && doc.coords.length > 0) {
const coords = doc.coords;
const bounds = coords.reduce((acc, coord) => {
return acc.extend(coord);
}, new maplibregl.LngLatBounds(coords[0], coords[0]));
map.fitBounds(bounds, { padding: 40, maxZoom: 19 });
}
}
let fullDxfGeoJson = null;
@ -981,119 +975,33 @@ function applyDxfVectorFilters() {
if (map.getSource('dxf-layout')) {
map.getSource('dxf-layout').setData(filteredData);
const isVisible = selectedLayers.length > 0;
['dxf-layout-fills', 'dxf-layout-lines', 'dxf-layout-symbols'].forEach(lId => {
if (map.getLayer(lId)) {
map.setLayoutProperty(lId, 'visibility', 'visible');
map.setLayoutProperty(lId, 'visibility', isVisible ? 'visible' : 'none');
}
});
reorderIntersectionLayers();
}
}
function updateButtonActiveState() {
const btn = document.getElementById('btn-load-georef-plan');
if (!btn) return;
let anyImageActive = false;
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
if (cb.checked) anyImageActive = true;
});
let anyVectorActive = false;
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
if (cb.checked) anyVectorActive = true;
});
if (anyImageActive || anyVectorActive) {
btn.classList.remove('btn-light', 'bg-white', 'border');
btn.classList.add('btn-primary');
} else {
btn.classList.remove('btn-primary');
btn.classList.add('btn-light', 'bg-white', 'border');
}
}
const noGeorefImagesAlert = document.getElementById('no-georef-images-alert');
const imagePlansList = document.getElementById('image-plans-list');
if (imagePlansList) {
imagePlansList.innerHTML = '';
if (georeferencedDocuments.length === 0) {
if (noGeorefImagesAlert) noGeorefImagesAlert.style.display = 'block';
} else {
if (noGeorefImagesAlert) noGeorefImagesAlert.style.display = 'none';
georeferencedDocuments.forEach(doc => {
const item = document.createElement('div');
item.className = 'image-plan-item p-3 bg-light border rounded-3 d-flex flex-column gap-2';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center">
<span class="text-dark font-weight-bold" style="font-size: 0.9rem;">
<i class="bi ${doc.is_dxf ? 'bi-file-earmark-code text-success' : 'bi-file-earmark-image text-primary'} me-2"></i>${doc.title}
</span>
<div class="form-check form-switch">
<input class="form-check-input image-plan-toggle" type="checkbox" role="switch" id="switch-georef-img-${doc.id}" data-id="${doc.id}">
</div>
</div>
<div class="image-plan-opacity-container" id="opacity-container-${doc.id}" style="display: none;">
<label class="form-label font-weight-semibold text-muted small uppercase d-flex justify-content-between mb-1">
<span>Opacité</span>
<span class="opacity-val text-primary font-weight-bold">95%</span>
</label>
<input type="range" class="form-range image-plan-opacity-slider" min="0" max="100" value="95" id="range-georef-img-opacity-${doc.id}" data-id="${doc.id}">
</div>
`;
imagePlansList.appendChild(item);
const toggle = item.querySelector('.image-plan-toggle');
const opacityContainer = item.querySelector('.image-plan-opacity-container');
const slider = item.querySelector('.image-plan-opacity-slider');
const opacityVal = item.querySelector('.opacity-val');
toggle.addEventListener('change', () => {
const active = toggle.checked;
if (active) {
opacityContainer.style.display = 'block';
} else {
opacityContainer.style.display = 'none';
}
const opacity = parseFloat(slider.value) / 100.0;
showGeorefImage(doc, active, opacity);
updateButtonActiveState();
});
slider.addEventListener('input', () => {
const val = slider.value;
opacityVal.textContent = val + '%';
const opacity = parseFloat(val) / 100.0;
showGeorefImage(doc, toggle.checked, opacity);
});
});
}
}
const dxfFileSelect = document.getElementById('select-dxf-file');
let detailLayersFetched = {};
let detailLayersResponseData = {};
function fetchDetailLayers() {
if (!dxfFileSelect) return;
const file = dxfFileSelect.value;
if (!file) {
const section = document.getElementById('dxf-detail-layers-section');
if (section) section.style.display = 'none';
return;
}
function fetchDetailLayers(file, autoCheckAll = false) {
if (!file) return;
const layersAccordion = document.getElementById('dxf-layers-accordion');
if (layersAccordion) layersAccordion.style.display = 'block';
if (detailLayersFetched[file]) {
renderDetailLayers(file);
renderDetailLayers(file, autoCheckAll);
fetchDxfGeoJson(file);
return;
}
const spinner = document.getElementById('dxf-detail-layers-spinner');
const list = document.getElementById('dxf-detail-layers-list');
const section = document.getElementById('dxf-detail-layers-section');
if (section) section.style.display = 'block';
if (spinner) spinner.style.display = 'block';
if (list) list.style.display = 'none';
@ -1102,41 +1010,39 @@ function fetchDetailLayers() {
.then(data => {
detailLayersFetched[file] = data.layers || [];
detailLayersResponseData[file] = data;
renderDetailLayers(file);
renderDetailLayers(file, autoCheckAll);
fetchDxfGeoJson(file);
if (spinner) spinner.style.display = 'none';
if (list) list.style.display = 'block';
if (list) list.style.display = 'flex';
})
.catch(err => {
console.error("Error fetching detail layers:", err);
if (spinner) {
spinner.innerHTML = `<div class="text-danger small"><i class="bi bi-exclamation-triangle-fill"></i> Erreur de chargement.</div>`;
spinner.innerHTML = `<div class="text-danger" style="font-size: 10px;"><i class="bi bi-exclamation-triangle-fill"></i> Erreur de chargement.</div>`;
}
});
}
function renderDetailLayers(file) {
function renderDetailLayers(file, autoCheckAll = false) {
const container = document.getElementById('dxf-detail-layers-list');
if (!container) return;
container.innerHTML = '';
const layers = detailLayersFetched[file] || [];
const responseData = detailLayersResponseData[file] || {};
const selectedLayers = responseData.selected_layers || [];
layers.forEach(layer => {
const isChecked = false;
const div = document.createElement('div');
div.className = 'form-check py-1 d-flex align-items-center justify-content-between';
div.className = 'd-flex align-items-center justify-content-between py-1 px-1 rounded';
div.style.fontSize = '11px';
div.innerHTML = `
<div class="d-flex align-items-center">
<span class="color-indicator me-2" style="display: inline-block; width: 14px; height: 14px; border-radius: 3px; background-color: ${layer.color}; border: 1px solid rgba(0,0,0,0.1);"></span>
<label class="form-check-label text-dark font-weight-medium small" for="dxf-detail-layer-${layer.name}">
<div class="d-flex align-items-center gap-1 text-truncate" style="max-width: 200px;">
<span class="color-indicator" style="width: 9px; height: 9px; border-radius: 2px; background-color: ${layer.color}; border: 1px solid rgba(0,0,0,0.15); flex-shrink: 0;"></span>
<label class="form-check-label text-truncate mb-0" for="dxf-detail-layer-${layer.name}" style="cursor: pointer; font-size: 11px; color: #334155;" title="${layer.name}">
${layer.name}
</label>
</div>
<div class="form-check form-switch">
<div class="form-check form-switch mb-0" style="padding-left: 1.8em;">
<input class="form-check-input dxf-vector-layer-toggle" type="checkbox" role="switch"
id="dxf-detail-layer-${layer.name}" data-layer="${layer.name}" ${isChecked ? 'checked' : ''}>
id="dxf-detail-layer-${layer.name}" data-layer="${layer.name}" ${autoCheckAll ? 'checked' : ''} style="cursor: pointer; width: 28px; height: 14px;">
</div>
`;
container.appendChild(div);
@ -1144,39 +1050,322 @@ function renderDetailLayers(file) {
const toggle = div.querySelector('.dxf-vector-layer-toggle');
toggle.addEventListener('change', () => {
applyDxfVectorFilters();
updateButtonActiveState();
updatePlansActiveState();
});
});
const totalCountEl = document.getElementById('dxf-total-layers-count');
if (totalCountEl) totalCountEl.textContent = layers.length;
updatePlansActiveState();
}
function updatePlansActiveState() {
let activeImages = 0;
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
if (cb.checked) activeImages++;
});
let activeDxfLayers = 0;
let totalDxfLayers = 0;
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
totalDxfLayers++;
if (cb.checked) activeDxfLayers++;
});
const activeCount = activeImages + (activeDxfLayers > 0 ? 1 : 0);
const hasAnyActive = activeImages > 0 || activeDxfLayers > 0;
const countBadge = document.getElementById('plans-active-count-badge');
if (countBadge) {
if (hasAnyActive) {
countBadge.style.display = 'inline-block';
countBadge.textContent = `${activeCount} actif${activeCount > 1 ? 's' : ''}`;
} else {
countBadge.style.display = 'none';
}
}
const dxfActiveCountEl = document.getElementById('dxf-active-layers-count');
if (dxfActiveCountEl) dxfActiveCountEl.textContent = activeDxfLayers;
const dxfTotalCountEl = document.getElementById('dxf-total-layers-count');
if (dxfTotalCountEl) dxfTotalCountEl.textContent = totalDxfLayers;
const switchDxfMaster = document.getElementById('switch-dxf-master');
if (switchDxfMaster) {
switchDxfMaster.checked = activeDxfLayers > 0;
}
const planDxfCard = document.getElementById('plan-dxf-card');
if (planDxfCard) {
planDxfCard.classList.toggle('active', activeDxfLayers > 0);
}
const footerActions = document.getElementById('plans-footer-actions');
if (footerActions) {
footerActions.style.display = hasAnyActive ? 'block' : 'none';
}
const dxfToggleAllBtn = document.getElementById('dxf-detail-toggle-all-layers');
if (dxfToggleAllBtn && totalDxfLayers > 0) {
dxfToggleAllBtn.textContent = (activeDxfLayers === totalDxfLayers) ? "Décocher tout" : "Tout cocher";
}
if (layerSwitcher && typeof layerSwitcher.setOverlayActive === 'function') {
layerSwitcher.setOverlayActive(hasAnyActive);
}
}
function renderCrossroadPlansSection(container, control) {
const importDxfUrl = crossroadPlansConfig && crossroadPlansConfig.importDxfUrl;
const hasImages = georeferencedDocuments && georeferencedDocuments.length > 0;
const hasDxf = availableDxfFiles && availableDxfFiles.length > 0;
// 1. Header with title & active badge
const headerEl = document.createElement('div');
headerEl.className = 'layer-switcher-subtitle';
headerEl.innerHTML = `
<span class="d-flex align-items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>
Plans du carrefour
</span>
<span class="badge bg-light text-primary border" id="plans-active-count-badge" style="font-size: 9px; display: none;">0 actif</span>
`;
container.appendChild(headerEl);
// Empty state if neither image nor DXF
if (!hasImages && !hasDxf) {
const emptyEl = document.createElement('div');
emptyEl.className = 'text-center py-2 px-1';
emptyEl.innerHTML = `
<div class="text-muted" style="font-size: 11px; margin-bottom: 4px;">Aucun plan géoréférencé</div>
${importDxfUrl ? `<a href="${importDxfUrl}" class="layer-switcher-btn-action"><i class="bi bi-upload"></i> Importer un plan DXF</a>` : ''}
`;
container.appendChild(emptyEl);
return;
}
// 2. Image Plans Sub-section
if (hasImages) {
const imgTitleEl = document.createElement('div');
imgTitleEl.className = 'layer-switcher-subtitle text-muted';
imgTitleEl.style.fontSize = '9px';
imgTitleEl.innerHTML = `<span><i class="bi bi-file-earmark-image me-1"></i> Plans images (${georeferencedDocuments.length})</span>`;
container.appendChild(imgTitleEl);
georeferencedDocuments.forEach(doc => {
const card = document.createElement('div');
card.className = 'layer-switcher-card';
card.id = `plan-img-card-${doc.id}`;
card.innerHTML = `
<div class="d-flex align-items-center justify-content-between">
<label class="form-check-label d-flex align-items-center gap-1 mb-0" for="switch-georef-img-${doc.id}" style="cursor: pointer; font-size: 11.5px; font-weight: 600; color: #1e293b; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${doc.title}">
<i class="bi ${doc.is_dxf ? 'bi-file-earmark-code text-success' : 'bi-file-earmark-image text-primary'}" style="flex-shrink: 0; font-size: 12px;"></i>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${doc.title}</span>
</label>
<div class="form-check form-switch mb-0" style="padding-left: 2em;">
<input class="form-check-input image-plan-toggle" type="checkbox" role="switch" id="switch-georef-img-${doc.id}" data-id="${doc.id}" style="cursor: pointer; width: 28px; height: 14px;">
</div>
</div>
<div class="layer-switcher-slider-row" id="opacity-container-${doc.id}" style="display: none;">
<label>Opacité</label>
<input type="range" class="form-range image-plan-opacity-slider" min="0" max="100" value="95" id="range-georef-img-opacity-${doc.id}" data-id="${doc.id}">
<span class="layer-switcher-slider-val opacity-val">95%</span>
</div>
`;
container.appendChild(card);
const toggle = card.querySelector('.image-plan-toggle');
const opacityRow = card.querySelector('.layer-switcher-slider-row');
const slider = card.querySelector('.image-plan-opacity-slider');
const opacityVal = card.querySelector('.opacity-val');
toggle.addEventListener('change', () => {
const active = toggle.checked;
card.classList.toggle('active', active);
opacityRow.style.display = active ? 'flex' : 'none';
const opacity = parseFloat(slider.value) / 100.0;
showGeorefImage(doc, active, opacity);
updatePlansActiveState();
});
slider.addEventListener('input', () => {
const val = slider.value;
opacityVal.textContent = val + '%';
const opacity = parseFloat(val) / 100.0;
const lyrId = `georef-image-overlay-layer-${doc.id}`;
if (map.getLayer(lyrId)) {
map.setPaintProperty(lyrId, 'raster-opacity', opacity);
}
});
});
}
if (dxfFileSelect) {
dxfFileSelect.addEventListener('change', () => {
fetchDetailLayers();
// 3. DXF Vector Plan Sub-section
if (hasDxf) {
const dxfTitleEl = document.createElement('div');
dxfTitleEl.className = 'layer-switcher-subtitle text-muted mt-1';
dxfTitleEl.style.fontSize = '9px';
dxfTitleEl.innerHTML = `<span><i class="bi bi-file-earmark-code me-1"></i> Plan CAO (DXF)</span>`;
container.appendChild(dxfTitleEl);
const dxfCard = document.createElement('div');
dxfCard.className = 'layer-switcher-card';
dxfCard.id = 'plan-dxf-card';
let dxfSelectHtml = '';
if (availableDxfFiles.length === 1) {
dxfSelectHtml = `
<input type="hidden" id="select-dxf-file" value="${availableDxfFiles[0]}">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="small font-weight-bold text-dark text-truncate" style="font-size: 11.5px; max-width: 190px;" title="${availableDxfFiles[0]}">
<i class="bi bi-file-earmark-check-fill text-success me-1"></i>${availableDxfFiles[0]}
</span>
<div class="form-check form-switch mb-0" style="padding-left: 2em;">
<input class="form-check-input" type="checkbox" role="switch" id="switch-dxf-master" style="cursor: pointer; width: 28px; height: 14px;" title="Afficher/masquer le plan CAO">
</div>
</div>
`;
} else {
dxfSelectHtml = `
<div class="d-flex align-items-center justify-content-between gap-1 mb-2">
<select id="select-dxf-file" class="form-select form-select-sm" style="font-size: 11px; padding: 2px 6px; flex-grow: 1;">
<option value="">-- Sélectionner un fichier DXF --</option>
${availableDxfFiles.map(f => `<option value="${f}">${f}</option>`).join('')}
</select>
<div class="form-check form-switch mb-0 ps-2">
<input class="form-check-input" type="checkbox" role="switch" id="switch-dxf-master" style="cursor: pointer; width: 28px; height: 14px;" title="Afficher/masquer le plan CAO">
</div>
</div>
`;
}
dxfCard.innerHTML = `
${dxfSelectHtml}
<div id="dxf-layers-accordion" style="display: none; margin-top: 4px;">
<div class="d-flex align-items-center justify-content-between py-1 px-1 bg-white border rounded mb-1" style="font-size: 10.5px;">
<button type="button" id="btn-toggle-dxf-accordion" class="btn btn-link p-0 text-decoration-none text-dark font-weight-bold d-flex align-items-center gap-1" style="font-size: 10.5px;">
<i class="bi bi-chevron-down" id="dxf-accordion-chevron" style="transition: transform 0.2s ease; font-size: 10px;"></i>
<span>Couches (<span id="dxf-active-layers-count">0</span>/<span id="dxf-total-layers-count">0</span>)</span>
</button>
<button type="button" id="dxf-detail-toggle-all-layers" class="btn btn-link p-0 text-decoration-none text-primary font-weight-semibold" style="font-size: 10px;">
Tout cocher
</button>
</div>
<div id="dxf-detail-layers-spinner" class="text-center py-2" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary" style="width: 14px; height: 14px; border-width: 1.5px;" role="status"></div>
<span class="text-muted ms-1" style="font-size: 10px;">Chargement...</span>
</div>
<div id="dxf-detail-layers-list" style="max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; padding-right: 2px;">
<!-- Checkboxes populated dynamically -->
</div>
</div>
`;
container.appendChild(dxfCard);
// Wire up DXF select and master toggle
const dxfSelectEl = dxfCard.querySelector('#select-dxf-file');
const dxfMasterSwitch = dxfCard.querySelector('#switch-dxf-master');
const accordionBtn = dxfCard.querySelector('#btn-toggle-dxf-accordion');
const accordionChevron = dxfCard.querySelector('#dxf-accordion-chevron');
const layersListEl = dxfCard.querySelector('#dxf-detail-layers-list');
const layersAccordion = dxfCard.querySelector('#dxf-layers-accordion');
const toggleAllBtn = dxfCard.querySelector('#dxf-detail-toggle-all-layers');
let isAccordionExpanded = true;
if (accordionBtn) {
accordionBtn.addEventListener('click', (e) => {
e.stopPropagation();
isAccordionExpanded = !isAccordionExpanded;
layersListEl.style.display = isAccordionExpanded ? 'flex' : 'none';
if (toggleAllBtn) toggleAllBtn.style.display = isAccordionExpanded ? 'inline' : 'none';
accordionChevron.style.transform = isAccordionExpanded ? 'rotate(0deg)' : 'rotate(-90deg)';
});
// Auto-load if there is a default select value or single dxf
if (dxfFileSelect.value) {
fetchDetailLayers();
}
}
const btnToggleAllLayers = document.getElementById('dxf-detail-toggle-all-layers');
if (btnToggleAllLayers) {
btnToggleAllLayers.addEventListener('click', () => {
const checkboxes = document.querySelectorAll('.dxf-vector-layer-toggle');
if (dxfMasterSwitch) {
dxfMasterSwitch.addEventListener('change', (e) => {
e.stopPropagation();
const isMasterOn = dxfMasterSwitch.checked;
dxfCard.classList.toggle('active', isMasterOn);
const file = dxfSelectEl ? dxfSelectEl.value : '';
if (!file) {
dxfMasterSwitch.checked = false;
return;
}
if (!detailLayersFetched[file]) {
fetchDetailLayers(file, isMasterOn);
} else {
const checkboxes = dxfCard.querySelectorAll('.dxf-vector-layer-toggle');
checkboxes.forEach(cb => {
cb.checked = isMasterOn;
});
applyDxfVectorFilters();
updatePlansActiveState();
}
if (isMasterOn) {
layersAccordion.style.display = 'block';
}
});
}
if (dxfSelectEl && dxfSelectEl.tagName === 'SELECT') {
dxfSelectEl.addEventListener('change', (e) => {
e.stopPropagation();
const file = dxfSelectEl.value;
if (file) {
fetchDetailLayers(file, dxfMasterSwitch.checked);
} else {
layersAccordion.style.display = 'none';
if (dxfMasterSwitch) dxfMasterSwitch.checked = false;
applyDxfVectorFilters();
updatePlansActiveState();
}
});
}
if (toggleAllBtn) {
toggleAllBtn.addEventListener('click', (e) => {
e.stopPropagation();
const checkboxes = dxfCard.querySelectorAll('.dxf-vector-layer-toggle');
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
checkboxes.forEach(cb => {
cb.checked = !allChecked;
});
btnToggleAllLayers.textContent = allChecked ? "Tout cocher" : "Décocher tout";
toggleAllBtn.textContent = allChecked ? "Tout cocher" : "Décocher tout";
applyDxfVectorFilters();
updateButtonActiveState();
updatePlansActiveState();
});
}
const btnDisableAllPlans = document.getElementById('btn-disable-all-plans');
if (btnDisableAllPlans) {
btnDisableAllPlans.addEventListener('click', () => {
// If single DXF, prepare/fetch layers
if (dxfSelectEl && dxfSelectEl.value) {
fetchDetailLayers(dxfSelectEl.value, false);
}
}
// 4. Footer Actions (Désactiver tous les plans)
const footerEl = document.createElement('div');
footerEl.id = 'plans-footer-actions';
footerEl.style.display = 'none';
footerEl.style.marginTop = '6px';
footerEl.innerHTML = `
<button type="button" id="btn-disable-all-plans" class="layer-switcher-btn-action btn-danger-light">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
Désactiver tous les plans
</button>
`;
container.appendChild(footerEl);
const btnDisableAll = footerEl.querySelector('#btn-disable-all-plans');
btnDisableAll.addEventListener('click', (e) => {
e.stopPropagation();
// Turn off image plans
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
if (cb.checked) {
cb.checked = false;
@ -1184,19 +1373,26 @@ if (btnDisableAllPlans) {
}
});
// Turn off DXF layers
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
if (cb.checked) {
cb.checked = false;
}
});
const switchMaster = document.getElementById('switch-dxf-master');
if (switchMaster) switchMaster.checked = false;
const dxfCard = document.getElementById('plan-dxf-card');
if (dxfCard) dxfCard.classList.remove('active');
applyDxfVectorFilters();
const modalEl = document.getElementById('georefPlanModal');
if (modalEl) {
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
}
updateButtonActiveState();
updatePlansActiveState();
});
}
// Register section with LayerSwitcherControl
if (layerSwitcher) {
layerSwitcher.addCustomSection({
id: 'crossroad-plans',
render: (container, control) => {
renderCrossroadPlansSection(container, control);
}
});
}

View file

@ -510,34 +510,66 @@ function zoomFormatter(value, row) {
function buildColumns() {
return [
{ field: 'check', checkbox: true, width: 36 },
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 },
{ field: 'check', checkbox: true, width: 36, widthUnit: 'px', align: 'center', valign: 'middle' },
{ field: 'actions', title: '', formatter: zoomFormatter, width: 44, widthUnit: 'px', align: 'center', valign: 'middle' },
{
field: 'code', title: gettext('Code'), sortable: true,
field: 'code', title: gettext('Code'), sortable: true, width: 135, widthUnit: 'px', valign: 'middle',
formatter: function (v, row) {
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`;
return v || '-';
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${escapeHtml(v || '-')}</a>`;
return `<span class="unified-code-link">${escapeHtml(v || '-')}</span>`;
}
},
{ field: 'name', title: gettext('Nom'), sortable: true },
{ field: 'element_type', title: gettext('Type'), sortable: true },
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true },
{ field: 'category', title: gettext('Catégorie'), sortable: true, visible: false },
{ field: 'model', title: gettext('Modèle'), sortable: true, visible: false },
{
field: 'status', title: gettext('Statut'), sortable: true, visible: false,
field: 'name', title: gettext('Nom'), sortable: true, minWidth: 220, valign: 'middle',
formatter: function (v) {
return `<span class="unified-row-name">${escapeHtml(v || '-')}</span>`;
}
},
{
field: 'element_type', title: gettext('Type'), sortable: true, width: 130, widthUnit: 'px', valign: 'middle',
formatter: function (v, row) {
const table = $('#unified-table');
let isCatVisible = false;
try {
isCatVisible = table.length && typeof table.bootstrapTable === 'function' &&
table.bootstrapTable('getVisibleColumns').some(c => c.field === 'category');
} catch (e) {}
if (!isCatVisible && row && row.is_location && row.category) {
return `${escapeHtml(v || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`;
}
return escapeHtml(v || '-');
}
},
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true, width: 140, widthUnit: 'px', valign: 'middle' },
{
field: 'category', title: gettext('Catégorie d\'assets'), sortable: true, width: 175, widthUnit: 'px', visible: false, valign: 'middle',
formatter: function (v, row) {
if (!v) return '-';
if (row && row.color && (row.thematic === 'publicbuildings' || row.thematic === 'buildings')) {
return `<span class="d-inline-flex align-items-center" style="gap:6px;"><span style="display:inline-block; width:9px; height:9px; border-radius:50%; background-color:${escapeHtml(row.color)}; flex-shrink:0; box-shadow:0 0 0 1px rgba(0,0,0,0.12);"></span><span class="unified-category-cell">${escapeHtml(v)}</span></span>`;
}
return `<span class="unified-category-cell">${escapeHtml(v)}</span>`;
}
},
{ field: 'model', title: gettext('Modèle'), sortable: true, width: 140, widthUnit: 'px', visible: false, valign: 'middle' },
{
field: 'status', title: gettext('Statut'), sortable: true, width: 110, widthUnit: 'px', visible: false, valign: 'middle',
formatter: function (v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; }
},
{
field: 'validation_status', title: gettext('Statut de validation'), sortable: true, visible: true,
field: 'validation_status', title: gettext('Statut de validation'), sortable: true, width: 135, widthUnit: 'px', visible: true, valign: 'middle',
formatter: function (v) {
const labels = {
'to_be_validated': gettext('À valider'),
'validated': gettext('Validé'),
'to_be_completed': gettext('À compléter'),
'rejected': gettext('Rejeté')
'to_be_validated': { text: gettext('À valider'), cls: 'bg-warning-subtle text-warning-emphasis border-warning-subtle' },
'validated': { text: gettext('Validé'), cls: 'bg-success-subtle text-success border-success-subtle' },
'to_be_completed': { text: gettext('À compléter'), cls: 'bg-info-subtle text-info-emphasis border-info-subtle' },
'rejected': { text: gettext('Rejeté'), cls: 'bg-danger-subtle text-danger border-danger-subtle' }
};
return labels[v] || v || '-';
const item = labels[v];
if (item) {
return `<span class="badge ${item.cls} border fw-medium" style="font-size: 0.74rem;">${item.text}</span>`;
}
return v || '-';
}
},
];
@ -597,7 +629,18 @@ function applyVisibleColumnsPreference(visibleColumns) {
* explicit thematic selection (GET params / locked_thematic_code).
*/
function applyInitialPreferences() {
if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE || (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS)) return;
if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE) return;
if (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS) {
if (!activeCategories || activeCategories.size === 0) {
getVisibleCategoryOptions().forEach(category => {
if (activeThematics.has(category.thematicCode)) {
activeCategories.add(category.value);
}
});
}
return;
}
if (ASSETS_INDEX_PREFS && typeof ASSETS_INDEX_PREFS === 'object' && Object.keys(ASSETS_INDEX_PREFS).length > 0) {
const prefs = ASSETS_INDEX_PREFS;
@ -722,7 +765,7 @@ $(document).ready(function () {
}
function calcHeight() {
return Math.max(300, $(window).height() - 210);
return Math.max(300, $(window).height() - 250);
}
$('#unified-table').bootstrapTable({
@ -761,8 +804,13 @@ $(document).ready(function () {
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
cb.addEventListener('change', function () {
if (this.checked) activeThematics.add(this.value);
else activeThematics.delete(this.value);
if (this.checked) {
activeThematics.add(this.value);
const newCats = THEMATIC_CATEGORIES[this.value] || [];
newCats.forEach(c => activeCategories.add(`${this.value}::${c.name || ''}`));
} else {
activeThematics.delete(this.value);
}
renderCategoryFilters();
renderModelFilters();
renderConditionalFilters();
@ -774,6 +822,7 @@ $(document).ready(function () {
cb.checked = true;
activeThematics.add(cb.value);
});
getVisibleCategoryOptions().forEach(category => activeCategories.add(category.value));
renderCategoryFilters();
renderModelFilters();
renderConditionalFilters();
@ -1271,6 +1320,10 @@ function hasTriggerFilter() {
if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true;
if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true;
if (outdoorFilters.pmr_accessible) return true;
const publicbuildingsFilters = activeConditionalFilters.publicbuildings || activeConditionalFilters.buildings || {};
if (Array.isArray(publicbuildingsFilters.usage_type) && publicbuildingsFilters.usage_type.length) return true;
if (publicbuildingsFilters.is_main_location) return true;
if (publicbuildingsFilters.is_erp) return true;
return false;
}
@ -1353,6 +1406,17 @@ function buildLocationsGeojsonPostBody(thematicCode) {
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
}
if (['publicbuildings', 'buildings'].includes(thematicCode)) {
if (Array.isArray(thematicFilters.usage_type) && thematicFilters.usage_type.length) {
payload.usage_type = thematicFilters.usage_type;
}
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
if (thematicFilters.is_erp) payload.is_erp = thematicFilters.is_erp;
if (Array.isArray(thematicFilters.location_type) && thematicFilters.location_type.length) {
payload.location_type = thematicFilters.location_type;
}
}
if (thematicCode === 'structures') {
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
payload.structure_type = thematicFilters.structure_type;
@ -1426,6 +1490,17 @@ function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
payload.asset_begin_date = thematicFilters.asset_begin_date;
}
}
if (['publicbuildings', 'buildings'].includes(thematicCode)) {
if (Array.isArray(thematicFilters.usage_type) && thematicFilters.usage_type.length) {
payload.usage_type = thematicFilters.usage_type;
}
if (thematicFilters.is_main_location) {
payload.is_main_location = thematicFilters.is_main_location;
}
if (thematicFilters.is_erp) {
payload.is_erp = thematicFilters.is_erp;
}
}
if (thematicCode === 'structures') {
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
payload.structure_type = thematicFilters.structure_type;
@ -1898,6 +1973,7 @@ function collectClickedLocations(clickEvent) {
thematic: p.thematic || '',
thematic_name: f._thematic_name || p.thematic || '',
element_type: p.element_type || '',
usage_type_display: p.usage_type_display || '',
detail_url: p.detail_url || null,
feature_id: id,
});
@ -1987,6 +2063,8 @@ function showAssetClickPopup(clickEvent) {
const code = escapeHtml(l.code || '-');
const type = escapeHtml(l.element_type || '');
const thematic = escapeHtml(l.thematic_name || l.thematic || '');
const usage = l.usage_type_display ? ` &bull; ${escapeHtml(l.usage_type_display)}` : '';
const dotHtml = l.color ? `<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background-color:${escapeHtml(l.color)}; margin-right:4px; vertical-align:middle; box-shadow:0 0 0 1px rgba(0,0,0,0.15);"></span>` : '';
const name = escapeHtml(l.name || '');
const detailAnchor = l.detail_url
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
@ -1996,7 +2074,7 @@ function showAssetClickPopup(clickEvent) {
<div class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
<div style="min-width:0;">
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${dotHtml}${type}${usage}${thematic ? ' - ' + thematic : ''}</div>
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
</div>
<button type="button"
@ -2146,7 +2224,39 @@ function fitMapToFeatures(features) {
// ─── Data loading ─────────────────────────────────────────────────────────────
let currentTerritoryMuniSignature = '';
function updateTerritoryMaskIfNeeded() {
if (!map || typeof map.getSource !== 'function') return;
const src = map.getSource('territory');
if (!src) return;
const selectedMunis = (typeof thematicFilters !== 'undefined' && Array.isArray(thematicFilters.municipality))
? thematicFilters.municipality
: [];
const sig = selectedMunis.slice().sort().join(',');
if (sig === currentTerritoryMuniSignature) return;
currentTerritoryMuniSignature = sig;
let url = window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/';
if (selectedMunis.length > 0) {
const params = selectedMunis.map(id => `municipality=${encodeURIComponent(id)}`).join('&');
url += (url.includes('?') ? '&' : '?') + params;
}
fetch(url)
.then(r => r.json())
.then(data => {
const currentSrc = map.getSource('territory');
if (currentSrc && data) currentSrc.setData(data);
})
.catch(err => {
console.warn('Could not update territory mask:', err);
});
}
function refreshData() {
updateTerritoryMaskIfNeeded();
// Cancel any in-flight requests from the previous call
if (currentFetchAbortController) { currentFetchAbortController.abort(); }
currentFetchAbortController = new AbortController();
@ -2386,6 +2496,7 @@ function loadTableData(features) {
const name = p.name || p.name_fr || p.name_nl || '';
const length = Number(p.length ?? p.length_m ?? 0);
const area = Number(p.area ?? p.area_m2 ?? p.surface_area ?? p.surface ?? 0);
const usageType = p.usage_type_display || '';
return {
id: f.id,
state: selectedIds.has(f.id),
@ -2396,7 +2507,10 @@ function loadTableData(features) {
thematic: p.thematic || '',
thematic_name: f._thematic_name || p.thematic || '',
category_id: p.category_id != null ? String(p.category_id) : '',
category: p.category || '',
category: p.category || usageType || '',
usage_type: p.usage_type || '',
usage_type_display: usageType,
color: p.color || p.display_color || '',
model: p.model || '',
status: p.status || '',
detail_url: p.detail_url || null,
@ -2523,6 +2637,10 @@ maploader.loadMap('map');
maploader.addEventListener('mapLoaded', () => {
// Create empty sources so setData can be called immediately after data loads
map.addSource('territory', {
type: 'geojson',
data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/'
});
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
@ -2576,40 +2694,13 @@ maploader.addEventListener('mapLoaded', () => {
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'ortho',
name: 'Orthophotos',
id: 'basemap',
name: 'Fonds de plan & Orthophotos',
icon: 'satellite',
group: 'ortho',
beforeLayer: 'locations',
layer: { type: 'raster', paint: { 'raster-opacity': 1 } },
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '\u00a9 Brussels UrbIS',
},
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: ['https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'],
tileSize: 256,
attribution: '\u00a9 Digitaal Vlaanderen - Orthofoto 2023',
},
},
],
beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'),
basemaps: window.MAP_BACKGROUNDS || [],
}],
});
// Reset view — fly back to initial Brussels Region view
});// Reset view — fly back to initial Brussels Region view
map.addControl({
onAdd(m) {
this._map = m;
@ -3620,7 +3711,7 @@ function renderConditionalFilters() {
const isChecked = selected === checkedValue;
return `
<div class="w-100 mb-2">
<div class="conditional-filter-block">
<div class="filter-options">
<div class="filter-option">
<input type="checkbox"
@ -3642,8 +3733,8 @@ function renderConditionalFilters() {
if (def.type === 'date') {
const inputId = `cond-${thematicCode}-${def.key}`;
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
<div class="conditional-filter-block">
<div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
<div class="filter-options">
<input type="date"
class="form-control form-control-sm conditional-filter-input"
@ -3669,8 +3760,8 @@ function renderConditionalFilters() {
).join('');
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
<div class="conditional-filter-block">
<div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
<div class="filter-options">
<select class="form-select form-select-sm conditional-filter-input"
name="${escapeHtml(filterName)}"
@ -3693,6 +3784,22 @@ function renderConditionalFilters() {
? (selected === String(option.value))
: selected.includes(String(option.value));
let iconClass = option.icon || '';
if (!iconClass && def.key === 'usage_type') {
const fallbackIcons = {
administrative: 'bi-building',
education: 'bi-mortarboard',
sports: 'bi-trophy',
culture: 'bi-palette',
social: 'bi-people',
technical: 'bi-tools',
housing: 'bi-house-door',
other: 'bi-building-gear'
};
iconClass = fallbackIcons[option.value] || 'bi-building';
}
const iconHtml = iconClass ? `<i class="bi ${escapeHtml(iconClass)} me-1"></i>` : '';
return `
<div class="filter-option">
<input type="${inputType}"
@ -3704,24 +3811,31 @@ function renderConditionalFilters() {
data-filter-type="${escapeHtml(def.type || 'multi')}"
value="${escapeHtml(String(option.value))}"
${isChecked ? 'checked' : ''}>
<label for="${optionId}">${escapeHtml(option.label)}</label>
<label for="${optionId}" title="${escapeHtml(option.label)}">${iconHtml}<span class="filter-option-text">${escapeHtml(option.label)}</span></label>
</div>
`;
}).join('');
const isUniformGrid = def.key === 'usage_type' || (def.options && def.options.length >= 4 && def.options.some(o => o.label && o.label.length > 15));
const optionsContainerClass = isUniformGrid ? 'filter-options filter-options-grid' : 'filter-options';
return `
<div class="conditional-filter-block">
<div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
<div class="${optionsContainerClass}">${optionsHtml}</div>
</div>
`;
}).join('');
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
<div class="filter-options">${optionsHtml}</div>
<div class="conditional-thematic-group">
<div class="conditional-thematic-header">
${icon}<span>${escapeHtml(heading)}</span>
</div>
`;
}).join('');
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div>
<div class="conditional-thematic-body">
${blocks}
</div>
</div>
`;
}).join('');

View file

@ -155,6 +155,20 @@ function loadUnifiedMapIcons(map) {
.catch(e => console.warn('unified_map_layers: could not load roaddrain icon', e));
}
// speedbump: dedicated speed cushion / ralentisseur SVG (44 px)
if (iconUrls.speedbump && !map.hasImage('icon-speedbump')) {
loadNormalisedIcon(iconUrls.speedbump, 44)
.then(data => { if (!map.hasImage('icon-speedbump')) map.addImage('icon-speedbump', data); })
.catch(e => console.warn('unified_map_layers: could not load speedbump icon', e));
}
// cleanglasscontainer: dedicated bulle à verre SVG (46 px)
if (iconUrls.cleanglasscontainer && !map.hasImage('icon-cleanglasscontainer')) {
loadNormalisedIcon(iconUrls.cleanglasscontainer, 46)
.then(data => { if (!map.hasImage('icon-cleanglasscontainer')) map.addImage('icon-cleanglasscontainer', data); })
.catch(e => console.warn('unified_map_layers: could not load cleanglasscontainer icon', e));
}
// roadconcreteblockset: dedicated concrete block SVG (42 px)
if (iconUrls.roadconcreteblockset && !map.hasImage('icon-roadconcreteblockset')) {
loadNormalisedIcon(iconUrls.roadconcreteblockset, 42)
@ -169,6 +183,13 @@ function loadUnifiedMapIcons(map) {
.catch(e => console.warn('unified_map_layers: could not load naturebench icon', e));
}
// natureplanter: dedicated planter / flower box SVG (38 px)
if (iconUrls.natureplanter && !map.hasImage('icon-natureplanter')) {
loadNormalisedIcon(iconUrls.natureplanter, 38)
.then(data => { if (!map.hasImage('icon-natureplanter')) map.addImage('icon-natureplanter', data); })
.catch(e => console.warn('unified_map_layers: could not load natureplanter icon', e));
}
// artworks: dedicated fountain/statue SVGs
if (iconUrls.artworkfountain && !map.hasImage('icon-artworkfountain')) {
loadNormalisedIcon(iconUrls.artworkfountain, 48)
@ -297,6 +318,38 @@ const THEMATIC_COLOR_EXPR = (typeof AssetSymbology !== 'undefined')
'#0022ff'
];
// ─── Layer 0 — territory mask (inverted polygon: subtle transparent gray outside the commune)
const _unified_territory_mask = {
id: 'territory-mask',
type: 'fill',
source: 'territory',
filter: ['==', ['get', 'layer'], 'mask'],
paint: {
'fill-color': '#0f172a',
'fill-opacity': 0.14,
'fill-antialias': false,
},
};
// ─── Layer 0b — territory boundary (delicate dashed outline around the commune)
const _unified_territory_outline = {
id: 'territory-outline',
type: 'line',
source: 'territory',
filter: ['==', ['get', 'layer'], 'boundary'],
paint: {
'line-color': '#475569',
'line-width': [
'interpolate', ['linear'], ['zoom'],
8, 1.2,
13, 1.8,
18, 2.5
],
'line-opacity': 0.7,
'line-dasharray': [4, 2],
},
};
// ─── Layer 1 — location fill (polygon locations: streets, areas) ──────────────
// id MUST stay 'locations': LayerSwitcherControl uses beforeLayer: 'locations'.
const _unified_locations_fill = {
@ -304,8 +357,29 @@ const _unified_locations_fill = {
type: 'fill',
source: 'locations',
paint: {
'fill-color': '#0094ff',
'fill-opacity': 0, // contour only — outline handled by locations-outline
'fill-color': [
'case',
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
[
'match',
['coalesce', ['get', 'usage_type'], ''],
'administrative', '#2563EB',
'education', '#D97706',
'sports', '#059669',
'culture', '#7C3AED',
'social', '#E11D48',
'technical', '#64748B',
'housing', '#0D9488',
'other', '#475569',
['coalesce', ['get', 'color'], '#2563EB']
],
'#0094ff'
],
'fill-opacity': [
'case',
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']], 0.45,
0 // contour only for standard street locations
],
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
},
};
@ -328,11 +402,23 @@ const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymb
['coalesce', ['get', 'color'], '#2e7d32'],
['==', ['get', 'thematic'], 'parking'],
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
['==', ['get', 'thematic'], 'roads'],
[
'match',
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#e03131',
['2', '2 - Mauvais'], '#f76707',
['3', '3 - Bon'], '#fcc419',
['4', '4 - Très bon'], '#74b816',
['5', '5 - Excellent'], '#2f9e44',
'#4a4a4a'
],
THEMATIC_COLOR_EXPR
],
'fill-opacity': [
'case',
['==', ['get', 'thematic'], 'parking'], 0.35,
['==', ['get', 'thematic'], 'roads'], 0.28,
0.7
],
'fill-antialias': false,
@ -354,14 +440,29 @@ const _unified_locations_outline = {
'line-color': [
'case',
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
[
'match',
['coalesce', ['get', 'usage_type'], ''],
'administrative', '#1D4ED8',
'education', '#B45309',
'sports', '#047857',
'culture', '#6D28D9',
'social', '#BE123C',
'technical', '#334155',
'housing', '#0F766E',
'other', '#1E293B',
'#1D4ED8'
],
'#0094ff'
],
'line-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 3,
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']], 2,
1
],
'line-opacity': 0.8,
'line-opacity': 0.9,
},
};
@ -382,8 +483,24 @@ const _unified_locations_circle = {
14, 5,
19, 10
],
// Colour by intersection status (matches trafficlights_index.html)
// Colour by building usage_type or intersection status
'circle-color': [
'case',
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
[
'match',
['coalesce', ['get', 'usage_type'], ''],
'administrative', '#2563EB',
'education', '#D97706',
'sports', '#059669',
'culture', '#7C3AED',
'social', '#E11D48',
'technical', '#64748B',
'housing', '#0D9488',
'other', '#475569',
['coalesce', ['get', 'color'], '#2563EB']
],
[
'match', ['get', 'status'],
'active', '#0094ff',
'in_preparation', '#f59f00',
@ -392,6 +509,7 @@ const _unified_locations_circle = {
'inactive', '#e03131',
'archived', '#6c757d',
'#0094ff'
]
],
'circle-stroke-color': [
'case',
@ -479,10 +597,251 @@ const _unified_assets_symbol = {
'icon-image': (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconImageExpr) ? AssetSymbology.getIconImageExpr() : 'icon-roads',
'icon-size': [
'interpolate', ['linear'], ['zoom'],
8, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.12, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.18, 0.4],
12, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.22, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.34, 0.6],
16, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.45, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.62, 0.8],
22, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.95, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 1.05, 1.2]
8, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.05,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.08,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.09,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.12,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.07,
['==', ['get', 'asset_type'], 'naturetree'], 0.08,
0.28
],
11, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.09,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.14,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.16,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.20,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.12,
['==', ['get', 'asset_type'], 'naturetree'], 0.14,
0.40
],
13, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.15,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.22,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.25,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.30,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.19,
['==', ['get', 'asset_type'], 'naturetree'], 0.22,
0.52
],
15, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.24,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.35,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.40,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.45,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.34,
['==', ['get', 'asset_type'], 'naturetree'], 0.38,
0.66
],
17, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.45,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.55,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.62,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.65,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.55,
['==', ['get', 'asset_type'], 'naturetree'], 0.62,
0.82
],
19, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.72,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.80,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.85,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.85,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.80,
['==', ['get', 'asset_type'], 'naturetree'], 0.85,
0.98
],
22, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.95,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 1.05,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 1.10,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 1.05,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 1.05,
['==', ['get', 'asset_type'], 'naturetree'], 1.10,
1.15
]
],
'icon-anchor': [
'case',
@ -783,6 +1142,8 @@ const _unified_assets_polygon_outline = (typeof AssetSymbology !== 'undefined' &
// MapLibre renders layers in insertion order, so the array below defines the
// visual stacking: fills at the bottom, then lines, then circles/symbols on top.
const UNIFIED_LAYERS = [
_unified_territory_mask, // 0. territory mask (outside commune)
_unified_territory_outline, // 0b. territory boundary line
_unified_locations_fill, // 1. polygon fills (bottom)
_unified_assets_fill, // 2. polygon fills
_unified_locations_outline, // 3. lines

View file

@ -101,7 +101,11 @@
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
8, 3,
8, 1.2,
11, 2.0,
13, 3.0,
15, 4.5,
17, 7.0,
22, 10
],
// display_color is a pre-computed canonical colour name (Python-side).

View file

@ -54,7 +54,7 @@
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</td>
<td>
<i class="bi bi-person me-1 text-muted"></i>{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}
<i class="bi bi-person me-1 text-muted"></i>{{ insp.inspector|short_name|default:"-" }}
</td>
<td>
<span class="small text-muted text-truncate d-inline-block" style="max-width: 220px;" title="{{ insp.details_summary }}">
@ -100,11 +100,31 @@
</div>
</div>
{% endif %}
{% if insp.related_intervention %}
<div class="mb-3">
<a href="{% url 'interventions:intervention_detail' insp.related_intervention.id %}" class="btn btn-outline-primary w-100 fw-semibold" target="_blank">
<i class="bi bi-tools me-2"></i>{% translate "Consulter l'intervention" %} {{ insp.related_intervention.code }}
</a>
</div>
{% endif %}
<ul class="list-group list-group-flush mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Équipement cible" %}</span>
<span class="fw-bold text-end ms-2">{{ insp.target_display }}</span>
</li>
{% if insp.related_intervention %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-tools me-1"></i>{% translate "Intervention liée" %}</span>
<span class="fw-semibold text-end ms-2">
<a href="{% url 'interventions:intervention_detail' insp.related_intervention.id %}" class="text-decoration-none fw-bold" target="_blank">
<i class="bi bi-box-arrow-up-right me-1"></i>{{ insp.related_intervention.code }}
</a>
{% if insp.related_intervention.sub_type %}
<span class="badge bg-light text-dark border ms-1">{{ insp.related_intervention.sub_type.get_name }}</span>
{% endif %}
</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
@ -119,7 +139,7 @@
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold">{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}</span>
<span class="fw-semibold">{{ insp.inspector|short_name|default:"-" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>

View file

@ -234,7 +234,7 @@
<div class="d-flex align-items-center gap-2">
{% block asset_header_title %}
<div>
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% translate "Asset" %} : {{ asset.code }}
</h2>
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;">
@ -278,7 +278,7 @@
{% if mobile_view or not allowed_update_fields %}
<div class="mb-3">
{% block asset_header_title_readonly %}
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% if thematic.icon %}
<i class="bi {{ thematic.icon }} me-2"></i>
{% endif %}
@ -546,7 +546,7 @@
</td>
</tr> -->
<tr class="table-light" style="--bs-table-bg-type: transparent; --bs-table-bg-state: transparent;">
<th colspan="2" class="text-muted" style="color: var(--brand-color) !important; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;">
<th colspan="2" class="text-muted" style="color: #475569 !important; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;">
{% translate "Attributs spécifiques" %}
</th>
</tr>

View file

@ -27,7 +27,7 @@
height: 44px;
border-radius: 0.75rem;
background-color: rgba(47, 62, 139, 0.1);
color: var(--brand-color, #2f3e8b);
color: #0f172a;
display: flex;
align-items: center;
justify-content: center;
@ -71,7 +71,7 @@
gap: 0.35rem;
}
.mobile-tab-btn.active {
background-color: var(--brand-color, #2f3e8b);
background-color: #0f172a;
color: #ffffff;
box-shadow: 0 2px 4px rgba(47, 62, 139, 0.25);
}
@ -137,7 +137,7 @@
</div>
<div class="flex-grow-1 text-truncate">
<div class="d-flex align-items-center gap-1.5 flex-wrap">
<span class="fw-bold text-dark fs-6" style="color: var(--brand-color);">{{ asset.code }}</span>
<span class="fw-bold text-dark fs-6" style="color: #0f172a;">{{ asset.code }}</span>
{% if asset.status == 'active' %}
<span class="badge bg-success">{{ asset.get_status_display }}</span>
{% elif asset.status == 'damaged' or asset.status == 'to_replace' %}

View file

@ -43,7 +43,7 @@
</div>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% translate "Arbre" %} : {{ asset.code }}
</h2>
{% if asset.name_fr %}
@ -114,7 +114,7 @@
</div>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% translate "Arbre" %} : {{ asset.code }}
</h2>
{% if asset.name_fr %}
@ -187,7 +187,7 @@
{# ── CARTE 1 : IDENTITÉ DE L'ARBRE ── #}
<div class="card mb-3 shadow-sm">
<div class="card-header bg-light d-flex align-items-center justify-content-between">
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
<i class="bi bi-info-circle text-primary me-2"></i>{% translate "Identité & Informations générales" %}
</h5>
{% if asset.tag_number %}
@ -384,7 +384,7 @@
{# ── CARTE 2 : DENDROMÉTRIE & DIMENSIONS ── #}
<div class="card mb-3 shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
<i class="bi bi-rulers text-success me-2"></i>{% translate "Fiche dendrométrique & Dimensions" %}
</h5>
</div>
@ -521,7 +521,7 @@
{# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #}
<div class="card mb-3 shadow-sm">
<div class="card-header bg-light d-flex align-items-center justify-content-between">
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
<i class="bi bi-shield-check text-warning me-2"></i>{% translate "Diagnostic sanitaire & Sécurité" %}
</h5>
{% if asset.is_remarkable %}
@ -698,7 +698,7 @@
{# ── CARTE 4 : IMPLANTATION & CONDUITE DE TAILLE ── #}
<div class="card mb-3 shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
<i class="bi bi-geo-alt text-info me-2"></i>{% translate "Implantation & Conduite de taille" %}
</h5>
</div>

View file

@ -111,17 +111,35 @@
filter: ['in', ['geometry-type'], ['literal', ['Polygon', 'MultiPolygon']]],
paint: {
'fill-color': [
'case',
['==', ['get', 'asset_type'], 'roadway'],
[
'match',
['get', 'asset_type'],
'roadway', '#4a4a4a',
'sidewalk', '#00bfa5',
'#9e9e9e'
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#e03131',
['2', '2 - Mauvais'], '#f76707',
['3', '3 - Bon'], '#fcc419',
['4', '4 - Très bon'], '#74b816',
['5', '5 - Excellent'], '#2f9e44',
'#4a4a4a'
],
'fill-opacity': 0.7,
['match', ['get', 'asset_type'], 'sidewalk', '#00bfa5', '#9e9e9e']
],
'fill-opacity': 0.28,
'fill-outline-color': [
'case',
['boolean', ['feature-state', 'highlighted'], false],
'#ffeb3b',
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
['==', ['get', 'asset_type'], 'roadway'],
[
'match',
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#c92a2a',
['2', '2 - Mauvais'], '#e8590c',
['3', '3 - Bon'], '#f59f00',
['4', '4 - Très bon'], '#66a80f',
['5', '5 - Excellent'], '#2b8a3e',
'#00000000'
],
'#00000000'
]
},
@ -175,8 +193,12 @@
'icon-image': 'drain-icon',
'icon-size': [
'interpolate', ['linear'], ['zoom'],
8, 0.1,
22, 1
8, 0.05,
11, 0.09,
13, 0.15,
15, 0.24,
17, 0.45,
22, 0.95
],
'icon-allow-overlap': true,
'icon-ignore-placement': true,

View file

@ -241,6 +241,12 @@
<script type="application/json" id="georeferencedDocumentsData">
{{ georeferenced_documents_json|safe }}
</script>
{{ dxf_files|json_script:"dxfFilesData" }}
<script type="application/json" id="crossroadPlansConfig">
{
"importDxfUrl": "{% url 'assets:import_dxf' intersection.id %}"
}
</script>
<!-- Modal form add document -->
<div class="modal fade" id="addDocumentModal" tabindex="-1" aria-labelledby="addDocumentModalLabel" aria-hidden="true" style="z-index: 10050;">
<div class="modal-dialog modal-lg">
@ -1108,107 +1114,6 @@
</div>
{% endif %}
<!-- Georeferenced Plans Selection Modal -->
<div class="modal fade" id="georefPlanModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content rounded-3 border-0 shadow-lg">
<div class="modal-header bg-light border-bottom-0 py-3">
<h5 class="modal-title font-weight-bold text-dark"><i class="bi bi-geo-alt-fill text-primary me-2"></i>{% translate "Plans géoréférencés" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body py-3">
<!-- Nav Tabs -->
<ul class="nav nav-tabs nav-fill mb-3" id="georefPlanTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active font-weight-bold" id="tab-image-plan" data-bs-toggle="tab" data-bs-target="#panel-image-plan" type="button" role="tab" aria-controls="panel-image-plan" aria-selected="true">
<i class="bi bi-file-earmark-image me-1"></i>{% translate "Plan image" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link font-weight-bold" id="tab-vector-plan" data-bs-toggle="tab" data-bs-target="#panel-vector-plan" type="button" role="tab" aria-controls="panel-vector-plan" aria-selected="false">
<i class="bi bi-file-earmark-code me-1"></i>{% translate "Plan CAO" %}
</button>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content pt-2" id="georefPlanTabContent">
<!-- Tab 1: Image Plan -->
<div class="tab-pane fade show active" id="panel-image-plan" role="tabpanel" aria-labelledby="tab-image-plan">
<div id="image-plan-selection-container">
<div id="image-plans-list" class="d-flex flex-column gap-3 mb-2">
<!-- Dynamically populated list of images with switches and sliders -->
</div>
<div id="no-georef-images-alert" class="text-center py-4" style="display: none;">
<div class="text-muted mb-3"><i class="bi bi-info-circle" style="font-size: 2.5rem;"></i></div>
<p class="text-dark font-weight-semibold mb-1">{% translate "Aucun plan d'image géoréférencé disponible" %}</p>
<p class="text-muted small">{% translate "Géoréférencez d'abord un plan PDF ou image dans l'onglet Documents de la fiche." %}</p>
</div>
</div>
</div>
<!-- Tab 2: Vector Plan (DXF) -->
<div class="tab-pane fade" id="panel-vector-plan" role="tabpanel" aria-labelledby="tab-vector-plan">
<div id="dxf-selection-container">
{% if not dxf_files %}
<div class="text-center py-4">
<div class="text-muted mb-3"><i class="bi bi-exclamation-triangle" style="font-size: 2.5rem;"></i></div>
<p class="text-dark font-weight-semibold mb-1">{% translate "Aucun fichier DXF disponible pour ce carrefour" %}</p>
<p class="text-muted small">{% translate "Veuillez d'abord en importer un." %}</p>
<a href="{% url 'assets:import_dxf' intersection.id %}" class="btn btn-primary rounded-3 btn-sm mt-2 px-3">
<i class="bi bi-upload me-1"></i>{% translate "Importer un plan DXF" %}
</a>
</div>
{% else %}
<!-- File Selection -->
<div class="mb-3">
<label class="form-label font-weight-semibold text-muted small uppercase">{% translate "Sélectionner le plan DXF" %}</label>
{% if dxf_files|length == 1 %}
<div class="p-3 bg-light border rounded-3 font-weight-semibold text-dark mb-3">
<i class="bi bi-file-earmark-check-fill text-success me-2"></i>{{ dxf_files.0 }}
<input type="hidden" id="select-dxf-file" value="{{ dxf_files.0 }}">
</div>
{% else %}
<select id="select-dxf-file" class="form-select rounded-3 p-3 mb-3">
<option value="">{% translate "-- Sélectionner un plan CAO (DXF) --" %}</option>
{% for f in dxf_files %}
<option value="{{ f }}">{{ f }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<!-- Layers selection checklist -->
<div id="dxf-detail-layers-section" style="display: none;" class="mb-3">
<label class="form-label font-weight-semibold text-muted small uppercase d-flex justify-content-between align-items-center mb-2">
<span>{% translate "Couches vectorielles CAO" %}</span>
<button type="button" id="dxf-detail-toggle-all-layers" class="btn btn-link btn-sm p-0 text-decoration-none" style="font-size: 0.75rem;">
{% translate "Décocher tout" %}
</button>
</label>
<div id="dxf-detail-layers-spinner" class="text-center py-4" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<div class="text-muted small mt-2">{% translate "Lecture des couches du plan..." %}</div>
</div>
<div id="dxf-detail-layers-list" class="border rounded-3 p-3 bg-white" style="max-height: 250px; overflow-y: auto;">
<!-- Checkboxes will be populated dynamically -->
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="modal-footer border-top-0 pt-0 pb-4 justify-content-between px-4">
<button type="button" id="btn-disable-all-plans" class="btn btn-outline-danger px-3 rounded-3">
<i class="bi bi-eye-slash-fill me-1"></i>{% translate "Désactiver tout" %}
</button>
<button type="button" class="btn btn-secondary px-4 rounded-3 font-weight-semibold shadow-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endblock content%}
@ -1232,7 +1137,6 @@
const radar_preventif_icon_url = "{% static 'assets/radar_preventif_icon.svg' %}?v=20260710";
const thematicCode = "{{ thematic_code }}";
const intersectionId = "{{ intersection.id }}";
const dxfFiles = {% if dxf_files %}{{ dxf_files|safe }}{% else %}[]{% endif %};
</script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'assets/trafficlights_intersection_detail.js' %}"></script>

View file

@ -9,44 +9,401 @@
<style>
#assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; }
#unified-empty-state { display: none; color: #6c757d; }
/* ─── Filter modal ──────────────────────────────────── */
#filterModal .modal-dialog { max-width: 95%; }
#filterModal .modal-content { display: flex; flex-direction: column; max-height: 90vh; }
#filterModal .modal-body { flex: 1; overflow-y: auto; }
#filterModal .modal-footer { flex-shrink: 0; }
.filter-section { margin-bottom: 1rem; }
/* ─── Modern Airbnb-style Filter Modal ──────────────────── */
#filterModal .modal-dialog {
max-width: 820px;
margin: 1.5rem auto;
}
#filterModal .modal-content {
border: 1px solid #e2e8f0;
border-radius: 18px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
max-height: 88vh;
overflow: hidden;
background-color: #ffffff;
}
#filterModal .modal-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #f1f5f9;
background-color: #ffffff;
}
#filterModal .modal-title {
font-size: 1.25rem;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.02em;
}
#filterModal .btn-close {
font-size: 0.85rem;
padding: 0.5rem;
opacity: 0.5;
transition: opacity 0.15s ease;
}
#filterModal .btn-close:hover {
opacity: 1;
}
#filterModal .modal-body {
padding: 1.5rem;
background-color: #f8fafc;
flex: 1;
overflow-y: auto;
}
#filterModal .modal-footer {
padding: 1rem 1.5rem;
background-color: #ffffff;
border-top: 1px solid #f1f5f9;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Thematic Conditional Filter Cards */
#conditional-filter-options {
display: flex;
flex-direction: column;
gap: 0.85rem;
width: 100%;
}
.conditional-thematic-group {
width: 100%;
box-sizing: border-box;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.95rem 1.05rem;
margin-bottom: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: all 0.15s ease;
}
.conditional-thematic-group:hover {
border-color: #cbd5e1;
background-color: #f1f5f9;
}
.conditional-thematic-header {
display: flex;
align-items: center;
gap: 0.55rem;
padding-bottom: 0.55rem;
margin-bottom: 0.75rem;
border-bottom: 1px solid #e2e8f0;
font-size: 0.9rem;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.01em;
}
.conditional-thematic-header i {
font-size: 1.05rem;
color: #2563eb;
}
.conditional-filter-block {
margin-bottom: 0.75rem;
}
.conditional-filter-block:last-child {
margin-bottom: 0;
}
.conditional-filter-label {
font-size: 0.76rem;
font-weight: 600;
color: #64748b;
margin-bottom: 0.35rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Uniform Responsive Grid for Filter Options (e.g. usage_type) */
.filter-options.filter-options-grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)) !important;
gap: 0.5rem !important;
width: 100% !important;
}
.filter-options.filter-options-grid .filter-option {
display: flex;
width: 100%;
min-width: 0;
}
.filter-options.filter-options-grid .filter-option label {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
padding: 0.45rem 0.85rem;
box-sizing: border-box;
min-height: 38px;
border-radius: 9999px;
overflow: hidden;
}
.filter-options.filter-options-grid .filter-option label .filter-option-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.filter-options.filter-options-grid .filter-option label i {
flex-shrink: 0;
font-size: 0.95rem;
margin-right: 0.35rem;
}
/* Filter Section Cards */
.filter-section {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 1.15rem;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.filter-section:hover {
border-color: #cbd5e1;
}
.filter-section-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.75rem; gap: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.85rem;
gap: 1rem;
}
.filter-section-header > label { font-weight: 600; color: #333; margin: 0; flex-shrink: 0; }
.filter-section-buttons { display: flex; gap: 0.5rem; margin-left: auto; flex-shrink: 0; }
.filter-section-buttons button { padding: 0.25rem 0.5rem; font-size: 0.75rem; white-space: nowrap; }
.filter-section-header > label,
.filter-section-header > .section-title {
font-size: 0.95rem;
font-weight: 700;
color: #0f172a;
margin: 0;
letter-spacing: -0.01em;
}
.filter-section-buttons {
display: flex;
gap: 0.35rem;
margin-left: auto;
}
.filter-section-buttons button {
padding: 0.25rem 0.65rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 6px;
border: 1px solid #e2e8f0;
background-color: #ffffff;
color: #64748b;
transition: all 0.15s ease;
}
.filter-section-buttons button:hover {
background-color: #f1f5f9;
color: #0f172a;
border-color: #cbd5e1;
}
/* Thematic Grid (Airbnb-style visual cards) */
.thematic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 0.65rem;
}
@media (max-width: 576px) {
.thematic-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.thematic-tile {
position: relative;
}
.thematic-tile input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.thematic-tile label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
min-height: 96px;
padding: 0.85rem 0.5rem;
background-color: #ffffff;
border: 1.5px solid #e2e8f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
}
.thematic-tile .thematic-icon {
font-size: 1.85rem;
color: #475569;
margin-bottom: 0.45rem;
transition: transform 0.18s ease, color 0.18s ease;
line-height: 1;
}
.thematic-tile .thematic-label {
font-size: 0.78rem;
font-weight: 600;
color: #334155;
line-height: 1.25;
transition: color 0.18s ease;
}
.thematic-tile:hover label {
background-color: #f8fafc;
border-color: #94a3b8;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.thematic-tile:hover .thematic-icon {
color: #0f172a;
transform: scale(1.08);
}
.thematic-tile input[type="checkbox"]:checked + label {
background-color: #0f172a !important;
border-color: #0f172a !important;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.22);
transform: translateY(-1px);
}
.thematic-tile input[type="checkbox"]:checked + label .thematic-icon {
color: #ffffff !important;
transform: scale(1.05);
}
.thematic-tile input[type="checkbox"]:checked + label .thematic-label {
color: #ffffff !important;
}
/* Chips & Tags (Categories, Models, Conditionals, Status) */
.filter-options {
display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 2.5rem;
padding: 0.5rem; background-color: #f8f9fa;
border-radius: 0.375rem; border: 1px solid #dee2e6;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
background-color: transparent !important;
border: none !important;
padding: 0 !important;
min-height: auto !important;
}
.filter-option {
display: inline-flex;
align-items: center;
}
.filter-option { display: flex; align-items: center; }
.filter-option input[type="checkbox"],
.filter-option input[type="radio"] {
position: absolute; opacity: 0; width: 0; height: 0; cursor: pointer;
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.filter-option label {
display: inline-flex; align-items: center; justify-content: center;
margin: 0; font-weight: 500; cursor: pointer;
background-color: #e7f1ff; color: #0c5cc9;
border-radius: 1rem; padding: 0.35rem 0.75rem; font-size: 0.8rem;
border: 1px solid #b8d4f1; transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.85rem;
font-size: 0.82rem;
font-weight: 500;
color: #334155;
background-color: #ffffff;
border: 1.5px solid #e2e8f0;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
white-space: nowrap;
}
.filter-option label:hover {
background-color: #f8fafc;
border-color: #94a3b8;
color: #0f172a;
}
.filter-option input[type="checkbox"]:checked + label,
.filter-option input[type="radio"]:checked + label {
background-color: #0c5cc9; color: #fff; border-color: #0c5cc9;
background-color: #0f172a !important;
border-color: #0f172a !important;
color: #ffffff !important;
font-weight: 600;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
}
.filter-option input:disabled + label {
opacity: 0.45;
cursor: not-allowed;
background-color: #f1f5f9;
border-color: #e2e8f0;
}
/* Segmented Control (Element Type) */
.segmented-control {
display: inline-flex;
background-color: #f1f5f9;
padding: 4px;
border-radius: 10px;
border: 1px solid #e2e8f0;
gap: 3px;
max-width: 100%;
overflow-x: auto;
}
.segmented-option label {
border-radius: 7px !important;
border: none !important;
background-color: transparent !important;
color: #64748b !important;
font-weight: 500 !important;
padding: 0.45rem 1rem !important;
font-size: 0.84rem !important;
box-shadow: none !important;
white-space: nowrap;
}
.segmented-option label:hover {
color: #0f172a !important;
background-color: rgba(255, 255, 255, 0.6) !important;
}
.segmented-option input[type="radio"]:checked + label {
background-color: #ffffff !important;
color: #0f172a !important;
font-weight: 600 !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08) !important;
}
/* Empty state helper inside filter options */
#category-filter-options > span.text-muted,
#model-filter-options > span.text-muted,
#conditional-filter-options > span.text-muted {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background-color: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #64748b !important;
font-size: 0.82rem;
width: 100%;
}
/* Filter trigger badge */
#filter-badge {
background-color: #0f172a !important;
color: #ffffff !important;
font-size: 0.72rem;
font-weight: 700;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
top: 2px !important;
right: -6px !important;
left: auto !important;
transform: none !important;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 4px !important;
line-height: 1.25rem;
}
#filter-badge:not(.d-none) {
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.filter-option label:hover { background-color: #cfe2f3; }
.filter-option input:checked + label:hover { background-color: #0a4fb0; }
.filter-option input:disabled + label { opacity: 0.5; cursor: not-allowed; }
#map .maplibregl-popup-close-button {
font-size: 1.35rem;
line-height: 1;
@ -70,6 +427,226 @@
#map .mapboxgl-ctrl-top-right {
top: 10px !important;
}
/* ─── Modern SaaS Table Styling ────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap');
#col-table,
.bootstrap-table {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
}
.bootstrap-table.bootstrap5 .fixed-table-container {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
background-color: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
overflow: hidden !important;
}
.bootstrap-table.bootstrap5 .fixed-table-body {
overflow: auto !important;
}
/* Modern subtle scrollbar */
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar {
width: 7px;
height: 7px;
}
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-track {
background: #f8fafc;
}
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
#col-table,
#col-table .tab-content,
#col-table .tab-pane {
overflow: hidden !important;
}
/* Headers */
.bootstrap-table.bootstrap5 .fixed-table-header th,
.bootstrap-table.bootstrap5 #unified-table th {
background-color: #f8fafc !important;
color: #475569 !important;
font-size: 0.74rem !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.04em !important;
border-bottom: 1.5px solid #e2e8f0 !important;
padding: 0.65rem 0.75rem !important;
vertical-align: middle !important;
white-space: nowrap !important;
}
.bootstrap-table.bootstrap5 .fixed-table-header th .th-inner {
padding: 0 !important;
font-size: 0.74rem !important;
font-weight: 600 !important;
line-height: 1.4 !important;
}
/* Rows & Cells */
.bootstrap-table.bootstrap5 #unified-table td {
padding: 0.65rem 0.75rem !important;
vertical-align: middle !important;
font-size: 0.83rem !important;
color: #334155;
border-bottom: 1px solid #f1f5f9 !important;
}
.bootstrap-table.bootstrap5 #unified-table tbody tr {
transition: background-color 0.12s ease;
}
.bootstrap-table.bootstrap5 #unified-table tbody tr:hover {
background-color: #f8fafc !important;
}
.bootstrap-table.bootstrap5 #unified-table tbody tr.selected {
background-color: #f1f5f9 !important;
}
/* Specific cell styles */
.unified-code-link {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
font-size: 0.79rem !important;
font-weight: 600 !important;
color: #0f172a !important;
text-decoration: none !important;
white-space: nowrap !important;
display: inline-block;
padding: 2px 6px;
border-radius: 5px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
transition: all 0.15s ease;
}
.unified-code-link:hover {
color: #2563eb !important;
border-color: #93c5fd;
background-color: #eff6ff;
}
.unified-row-name {
font-weight: 500;
color: #0f172a;
display: block;
line-height: 1.35;
}
.unified-category-cell {
color: #334155;
font-size: 0.82rem;
line-height: 1.35;
}
/* Prevent unwanted wraps */
#unified-table td[data-field="code"],
#unified-table th[data-field="code"] {
white-space: nowrap !important;
}
#unified-table td[data-field="thematic_name"],
#unified-table th[data-field="thematic_name"] {
white-space: nowrap !important;
color: #475569;
}
#unified-table td[data-field="element_type"] {
white-space: nowrap;
}
/* Zoom button */
.zoom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
color: #64748b;
background-color: transparent;
border: 1px solid transparent;
transition: all 0.15s ease;
}
.zoom-btn:hover {
color: #0f172a;
background-color: #f1f5f9;
border-color: #cbd5e1;
}
/* Selection toolbar styling */
#unified-select-all,
#unified-deselect-all {
background-color: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 500;
padding: 0.28rem 0.65rem;
transition: all 0.15s ease;
}
#unified-select-all:hover,
#unified-deselect-all:hover {
background-color: #f1f5f9;
color: #0f172a;
border-color: #cbd5e1;
}
#unified-selection-status {
font-size: 0.8rem;
color: #64748b;
font-weight: 500;
}
#unified-bulk-action-dropdown .dropdown-toggle {
background-color: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 500;
padding: 0.28rem 0.75rem;
}
#unified-bulk-action-dropdown .dropdown-toggle:hover {
background-color: #f1f5f9;
color: #0f172a;
border-color: #cbd5e1;
}
/* Pagination bar styling */
.bootstrap-table.bootstrap5 .fixed-table-pagination {
padding: 0.65rem 0.25rem 0 0.25rem !important;
font-size: 0.82rem;
color: #64748b;
}
.bootstrap-table.bootstrap5 .pagination-detail .page-list .btn-group .btn {
border-radius: 6px;
border-color: #e2e8f0;
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
background-color: #ffffff;
color: #334155;
}
.bootstrap-table.bootstrap5 .pagination .page-item .page-link {
border-radius: 6px;
margin: 0 2px;
border-color: #e2e8f0;
color: #334155;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
}
.bootstrap-table.bootstrap5 .pagination .page-item.active .page-link {
background-color: #0f172a;
border-color: #0f172a;
color: #ffffff;
}
</style>
{% endblock head %}
@ -77,45 +654,78 @@
{# ─── Modal de filtre ──────────────────────────────────────────────────────── #}
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les assets" %}</h5>
<div class="text-muted small" style="font-size: 0.8rem;">{% translate "Sélectionnez les thématiques et critères à afficher sur la carte et dans le tableau" %}</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
{# Type d'élément #}
<div class="filter-section">
<div class="filter-section-header">
<label>{% translate "Type d'élément" %}</label>
</div>
<div class="segmented-control">
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-all" value="all"
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-all">{% translate "Assets & localisations" %}</label>
</div>
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
{% if preventive_element_type == 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
</div>
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
{% if preventive_element_type == 'assets' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-assets">{% translate "Assets uniquement" %}</label>
</div>
</div>
</div>
{# Thématiques #}
<div class="filter-section">
<div class="filter-section-header">
<div>
<label>{% translate "Thématique" %}</label>
</div>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary" id="thematic-select-all">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="thematic-deselect-all">{% translate "Aucun" %}</button>
<button type="button" id="thematic-select-all">{% translate "Toutes" %}</button>
<button type="button" id="thematic-deselect-all">{% translate "Aucune" %}</button>
</div>
</div>
<div class="filter-options">
<div class="thematic-grid">
{% for thematic in accessible_thematics %}
<div class="filter-option">
<div class="thematic-tile">
<input type="checkbox" class="thematic-checkbox"
id="thematic-cb-{{ thematic.code }}"
value="{{ thematic.code }}"
{% if thematic.code in selected_thematics %}checked{% endif %}>
<label for="thematic-cb-{{ thematic.code }}">
{% if thematic.icon %}<i class="bi {{ thematic.icon }} me-1"></i>{% endif %}{{ thematic.name }}
{% if thematic.icon %}<i class="bi {{ thematic.icon }} thematic-icon"></i>{% endif %}
<span class="thematic-label">{{ thematic.name }}</span>
</label>
</div>
{% endfor %}
</div>
</div>
{# Catégories #}
{# Catégories d'assets #}
<div class="filter-section">
<div class="filter-section-header">
<label>{% translate "Catégorie" %}</label>
<label>{% translate "Catégorie d'assets" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary" id="category-select-all">{% translate "Toutes" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="category-deselect-all">{% translate "Aucune" %}</button>
<button type="button" id="category-select-all">{% translate "Toutes" %}</button>
<button type="button" id="category-deselect-all">{% translate "Aucune" %}</button>
</div>
</div>
<div id="category-filter-options" class="filter-options">
@ -128,9 +738,9 @@
<div class="filter-section-header">
<label>{% translate "Modèle" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary" id="model-select-all">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="model-deselect-all">{% translate "Aucun" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="model-filter-toggle"></button>
<button type="button" id="model-select-all">{% translate "Tous" %}</button>
<button type="button" id="model-deselect-all">{% translate "Aucun" %}</button>
<button type="button" class="d-none" id="model-filter-toggle"></button>
</div>
</div>
<div id="model-filter-options" class="filter-options">
@ -148,33 +758,6 @@
</div>
</div>
{# Type d'élément #}
<div class="filter-section">
<div class="filter-section-header">
<label>{% translate "Type d'élément" %}</label>
</div>
<div class="filter-options">
<div class="filter-option">
<input type="radio" name="unified-type-filter" id="type-all" value="all"
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-all">{% translate "Assets et localisations" %}</label>
</div>
<div class="filter-option">
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
{% if preventive_element_type == 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
</div>
<div class="filter-option">
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
{% if preventive_element_type == 'assets' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-assets">{% translate "Assets uniquement" %}</label>
</div>
</div>
</div>
{# Options de géométrie #}
<div class="filter-section">
<div class="filter-section-header">
@ -183,14 +766,17 @@
<div class="filter-options">
<div class="filter-option">
<input type="checkbox" name="include-no-geom" id="include-no-geom" checked>
<label for="include-no-geom">{% translate "Inclure les assets sans géométrie" %}</label>
<label for="include-no-geom">
<i class="bi bi-geo-alt me-1"></i>{% translate "Inclure les assets sans géométrie" %}
</label>
</div>
</div>
</div>
{# Statut #}
<div class="row">
<div class="col-12 filter-section">
{# Statut & Statut de validation #}
<div class="row g-3 mb-1">
<div class="col-md-6">
<div class="filter-section h-100 mb-0">
<div class="filter-section-header">
<label>{% translate "Statut" %}</label>
</div>
@ -204,12 +790,10 @@
</div>
</div>
</div>
{# Statut de validation #}
<div class="row">
<div class="col-12 filter-section">
<div class="col-md-6">
<div class="filter-section h-100 mb-0">
<div class="filter-section-header">
<label>{% translate "Statut de validation" %}</label>
<label>{% translate "Validation" %}</label>
</div>
<div class="filter-options">
{% for validation_status_value, validation_status_label in asset_validation_status_choices %}
@ -221,16 +805,24 @@
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" id="reset-filters" class="btn btn-secondary">{% translate "Réinitialiser" %}</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" id="apply-filters" class="btn btn-primary">{% translate "Filtrer" %}</button>
<button type="button" id="reset-filters" class="btn btn-link text-muted p-0 text-decoration-none d-flex align-items-center gap-1" style="font-size: 0.85rem; font-weight: 500;">
<i class="bi bi-arrow-counterclockwise"></i> {% translate "Tout effacer" %}
</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary px-3 py-2" data-bs-dismiss="modal" style="border-radius: 8px; font-weight: 500;">{% translate "Fermer" %}</button>
<button type="button" id="apply-filters" class="btn btn-sm btn-dark px-4 py-2" style="background-color: #0f172a; border-color: #0f172a; border-radius: 8px; font-weight: 600;">
<i class="bi bi-check2 me-1"></i>{% translate "Appliquer les filtres" %}
</button>
</div>
</div>
</div>
</div>
</div>
{# ─── Add chooser modal ────────────────────────────────────────────────────── #}
<div class="modal fade" id="unifiedAddChooserModal" tabindex="-1" aria-labelledby="unifiedAddChooserModalLabel" aria-hidden="true">
@ -478,7 +1070,7 @@
data-bs-toggle="modal" data-bs-target="#filterModal"
title="{% translate 'Filtres' %}">
<i class="bi bi-funnel"></i> {% translate "Filtres" %}
<span id="filter-badge" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-primary d-none">
<span id="filter-badge" class="position-absolute badge rounded-pill bg-primary d-none">
<span id="filter-count">0</span>
</span>
</button>
@ -703,6 +1295,7 @@
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script>
window.TERRITORY_MASK_URL = "{% url 'common:get_territory_mask_geojson' %}";
const preventive_draft_id = "{{ preventive_draft_id|default:'' }}";
var selected_locations_set = new Set({{ selected_locations_ids|safe }});
const initial_selected_assets = {{ selected_assets_json|safe }};
@ -741,6 +1334,9 @@
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a",
structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b",
roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a",
speedbump: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
roadfurniture: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
cleanglasscontainer: "{% static 'assets/clean_glass_container_icon.svg' %}?v=20260920",
roadconcreteblockset: "{% static 'assets/road_concrete_block_icon.svg' %}?v=20260608a",
naturebench: "{% static 'assets/nature_bench_icon.svg' %}",
artworkfountain: "{% static 'assets/artwork_fountain_icon.svg' %}?v=20260521a",
@ -752,7 +1348,7 @@
<script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
<script src="{% static 'assets/asset_symbology.js' %}?v=20260913"></script>
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260726_pb"></script>
<script src="{% static 'assets/unified_index.js' %}?v=20260726_pb"></script>
<script src="{% static 'assets/asset_symbology.js' %}?v=20260920_grid_options"></script>
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260920_grid_options"></script>
<script src="{% static 'assets/unified_index.js' %}?v=20260920_grid_options"></script>
{% endblock script %}

View file

@ -2008,7 +2008,7 @@ class TrafficLightCrossPlanImportTest(TestCase):
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook: Pedestrian phase aF with HP line
# Workbook: Cyclo-pedestrian phase bF with HP line
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "kruisjesplan"
@ -2019,10 +2019,53 @@ class TrafficLightCrossPlanImportTest(TestCase):
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
# Row 8: Pedestrian phase aF
# Row 8: Cyclo-pedestrian phase bF
ws.cell(row=8, column=1, value="com")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="aF")
ws.cell(row=8, column=3, value="bF")
ws.cell(row=8, column=5, value="X")
# Row 9: HP
ws.cell(row=9, column=1, value="com")
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="HP")
ws.cell(row=9, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
lanterns = deduced["lanterns"]
self.assertEqual(len(lanterns), 1)
self.assertEqual(lanterns[0]["model_name"], "2V200 cyclo-piéto + HP")
self.assertEqual(lanterns[0]["code"], "SWB01_B01_LAN01_bF")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_pure_pedestrian_hp(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook: Pure pedestrian phase 'a' with HP line
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")
# Row 8: Pure pedestrian phase 'a'
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")
# Row 9: HP
@ -2040,7 +2083,86 @@ class TrafficLightCrossPlanImportTest(TestCase):
lanterns = deduced["lanterns"]
self.assertEqual(len(lanterns), 1)
self.assertEqual(lanterns[0]["model_name"], "2V200 piéton+HP")
self.assertEqual(lanterns[0]["code"], "SWB01_B01_LAN01_aF")
self.assertEqual(lanterns[0]["code"], "SWB01_B01_LAN01_a")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_hp_ls_common_wire_not_an_equipment(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook with pedestrian pole B01, vehicular-only pole A01,
# and a common wire HP/LS (42V) spanning across both poles.
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="A01")
# Row 8: Vehicle phase A on A01
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="A")
ws.cell(row=8, column=4, value="G")
ws.cell(row=8, column=6, value="X")
# Row 9: Pedestrian phase a on B01
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="a")
ws.cell(row=9, column=4, value="gr")
ws.cell(row=9, column=5, value="X")
# Row 10: Common wire HP/LS (42V) connected to both B01 and A01
ws.cell(row=10, column=2, value="55")
ws.cell(row=10, column=3, value="HP/LS")
ws.cell(row=10, column=4, value="42V")
ws.cell(row=10, column=5, value="X")
ws.cell(row=10, column=6, value="X")
# Row 11: Common wire RADAR (42V)
ws.cell(row=11, column=2, value="56")
ws.cell(row=11, column=3, value="RADAR")
ws.cell(row=11, column=4, value="42V")
ws.cell(row=11, column=5, value="X")
# Row 12: Common wire Com (42V)
ws.cell(row=12, column=2, value="58")
ws.cell(row=12, column=3, value="Com")
ws.cell(row=12, column=4, value="42V")
ws.cell(row=12, column=5, value="X")
ws.cell(row=12, column=6, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
lanterns = deduced["lanterns"]
detectors = deduced["detectors"]
# Exactly 2 lanterns: 1 vehicle for A on A01, 1 pedestrian for a on B01
self.assertEqual(len(lanterns), 2)
lan_a = next(l for l in lanterns if l["phase"] == "A")
self.assertEqual(lan_a["code"], "SWB01_A01_LAN01_A")
self.assertEqual(lan_a["model_name"], "3V200")
lan_ped = next(l for l in lanterns if l["phase"] == "a")
self.assertEqual(lan_ped["code"], "SWB01_B01_LAN01_a")
# B01 has HP/LS common wire, so pedestrian lantern gets piéton+HP
self.assertEqual(lan_ped["model_name"], "2V200 piéton+HP")
# Crucial assertion: NO equipment (lantern or detector) created for HP/LS, RADAR, or Com
self.assertFalse(any("HP" in l["code"] or "LS" in l["code"] for l in lanterns))
self.assertEqual(len(detectors), 0)
finally:
import os
if os.path.exists(tmp_path):
@ -2313,6 +2435,596 @@ 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)
def test_match_pole_lanterns_intelligence_user_scenario(self):
"""
Tests the intelligent matching of lanterns when multiple lanterns are on the same pole.
In the user scenario:
- DB has:
LAN01: 3V200 tram tout droite
LAN02: 3V300 fl gauche
- Plan deduces:
LAN01_B1: 3V300 (vehicular phase B1)
LAN02_T2: 3V300 tram tout droit (tram phase T2)
The algorithm must NOT naively match LAN01_B1 to DB LAN01 and LAN02_T2 to DB LAN02.
Instead, it must match:
LAN01_B1 -> DB LAN02 (vehicular to vehicular)
LAN02_T2 -> DB LAN01 (tram to tram)
"""
from assets.utils.cross_plan_parser import match_pole_lanterns
pole_dls = [
{'code': 'SEK02_B02_LAN01_B1', 'model_name': '3V300', 'name': '3V300 sur B02 - Phase B1', 'phase': 'B1', 'pole_code': 'SEK02_B02'},
{'code': 'SEK02_B02_LAN02_T2', 'model_name': '3V300 tram tout droit', 'name': '3V300 tram sur B02 - Phase T2', 'phase': 'T2', 'pole_code': 'SEK02_B02'}
]
class DummyModel:
def __init__(self, name_fr, name_nl=''):
self.name_fr = name_fr
self.name_nl = name_nl
class DummyLantern:
def __init__(self, code, name_fr, model_name):
self.code = code
self.name_fr = name_fr
self.model = DummyModel(model_name)
el_tram = DummyLantern('SEK02_B02_LAN01', '3V200 tram tout droite', '3V200 tram tout droite')
el_car = DummyLantern('SEK02_B02_LAN02', '3V300 fl gauche', '3V300 fl gauche')
pole_els = [el_tram, el_car]
matched_pairs, unmatched_dls, unmatched_els = match_pole_lanterns(pole_dls, pole_els)
self.assertEqual(len(matched_pairs), 2)
self.assertEqual(len(unmatched_dls), 0)
self.assertEqual(len(unmatched_els), 0)
match_map = {dl['code']: el.code for dl, el in matched_pairs}
self.assertEqual(match_map['SEK02_B02_LAN01_B1'], 'SEK02_B02_LAN02')
self.assertEqual(match_map['SEK02_B02_LAN02_T2'], 'SEK02_B02_LAN01')
def test_match_pole_lanterns_cyclopieton_bf(self):
"""
Tests the user scenario on pole B01 with multiple lanterns:
DB has:
LAN01_A (3V200)
LAN02_A (3V300)
LAN03 (2V200 cyclo-piéto + HP)
Plan deduces:
LAN01_A (3V200)
LAN02_A (3V300)
LAN03_bF (2V200 cyclo-piéto + HP, phase bF)
Must match LAN03_bF -> DB LAN03 instead of archiving LAN03 and creating a new asset.
"""
from assets.utils.cross_plan_parser import match_pole_lanterns
class DummyModel:
def __init__(self, name_fr):
self.name_fr = name_fr
self.name_nl = ''
class DummyLantern:
def __init__(self, code, name_fr, model_name):
self.code = code
self.name_fr = name_fr
self.model = DummyModel(model_name)
pole_dls = [
{'code': 'SPW10_B01_LAN01_A', 'model_name': '3V200', 'name': '3V200 sur B01 - Phase A', 'phase': 'A', 'pole_code': 'SPW10_B01'},
{'code': 'SPW10_B01_LAN02_A', 'model_name': '3V300', 'name': '3V300 sur B01 - Phase A', 'phase': 'A', 'pole_code': 'SPW10_B01'},
{'code': 'SPW10_B01_LAN03_bF', 'model_name': '2V200 cyclo-piéto + HP', 'name': '2V200 cyclo-piéto + HP sur B01 - Phase bF', 'phase': 'bF', 'pole_code': 'SPW10_B01'},
]
el_v1 = DummyLantern('SPW10_B01_LAN01_A', '3V200', '3V200')
el_v2 = DummyLantern('SPW10_B01_LAN02_A', '3V300', '3V300')
el_cyclo = DummyLantern('SPW10_B01_LAN03', '2V200 cyclo-piéto + HP', '2V200 cyclo-piéto + HP')
pole_els = [el_v1, el_v2, el_cyclo]
matched_pairs, unmatched_dls, unmatched_els = match_pole_lanterns(pole_dls, pole_els)
self.assertEqual(len(matched_pairs), 3)
self.assertEqual(len(unmatched_dls), 0)
self.assertEqual(len(unmatched_els), 0)
match_map = {dl['code']: el.code for dl, el in matched_pairs}
self.assertEqual(match_map['SPW10_B01_LAN03_bF'], 'SPW10_B01_LAN03')
def test_import_cross_plan_stib_detector_preserved_by_default(self):
"""
Tests that when a DB detector is named dT1 or dT2 (STIB tram loop),
the preview recognizes it as STIB, sets action_default='keep', and proposes
to preserve it by default in the HTML decision select box.
"""
import openpyxl
import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from assets.models import TrafficLightDetector, TrafficLightDetectorModel
det_model = TrafficLightDetectorModel.objects.first()
stib_det = TrafficLightDetector.objects.create(
intersection=self.intersection,
code="SPW10_DET_1",
name="dT1",
name_fr="dT1",
model=det_model,
status="active"
)
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)
try:
self.client.force_login(self.user)
with open(tmp_path, "rb") as f:
uploaded = SimpleUploadedFile("test_cross_plan.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
url = reverse("assets:import_cross_plan", args=[self.intersection.id])
response = self.client.post(url, {"cross_plan_file_upload": uploaded})
self.assertEqual(response.status_code, 200)
detectors = response.context["detectors"]
stib_item = next((d for d in detectors if d["code"] == "SPW10_DET_1"), None)
self.assertIsNotNone(stib_item)
self.assertEqual(stib_item["status"], "db_only")
self.assertEqual(stib_item["action_default"], "keep")
self.assertTrue(stib_item["is_stib"])
self.assertIn("STIB", stib_item["details_diff"][0])
# Verify in HTML that 'Conserver en DB' is selected
html = response.content.decode("utf-8")
self.assertIn('name="action_detector_db_SPW10_DET_1"', html)
self.assertIn('value="keep" selected', html)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_import_cross_plan_multiple_lanterns_preview(self):
"""
End-to-end view test with multiple lanterns on the same pole.
Verifies that import_cross_plan view correctly associates the lanterns in compared_lanterns
and populates the preview with the correct db_id and diffs.
"""
import openpyxl
import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from assets.models import TrafficLightPole, TrafficLightLantern, TrafficLightPoleModel, TrafficLightLanternModel, AssetCategory
pole_model = TrafficLightPoleModel.objects.first()
m_tram, _ = TrafficLightLanternModel.objects.get_or_create(code="3v200_tram", defaults={"name_fr": "3V200 tram tout droite", "voltage": 230})
m_turn, _ = TrafficLightLanternModel.objects.get_or_create(code="3v300_fl", defaults={"name_fr": "3V300 fl gauche", "voltage": 230})
TrafficLightLanternModel.objects.get_or_create(code="3v300_tram", defaults={"name_fr": "3V300 tram tout droit", "voltage": 230})
TrafficLightLanternModel.objects.get_or_create(code="3v300", defaults={"name_fr": "3V300", "voltage": 230})
category_pole = AssetCategory.objects.filter(code="TL_POLE").first()
category_lan = AssetCategory.objects.filter(code="TL_LANTERN").first() or AssetCategory.objects.filter(code="TL_LANTERNE").first()
pole_b02 = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_B02",
model=pole_model,
category=category_pole,
status="active"
)
db_lan_tram = TrafficLightLantern.objects.create(
pole=pole_b02,
code="SWB01_B02_LAN01",
model=m_tram,
name_fr="3V200 tram tout droite",
category=category_lan,
status="active"
)
db_lan_car = TrafficLightLantern.objects.create(
pole=pole_b02,
code="SWB01_B02_LAN02",
model=m_turn,
name_fr="3V300 fl gauche",
category=category_lan,
status="active"
)
# Create Excel cross plan with pole B02 having phase B1 (vehicle) and T2 (tram)
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="B02")
# Row 8: Phase B1 (Vehicle)
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="B1")
ws.cell(row=8, column=4, value="Y")
ws.cell(row=8, column=5, value="X")
# Row 9: Phase T2 (Tram)
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="T2")
ws.cell(row=9, column=4, value="Y")
ws.cell(row=9, column=5, 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)
with open(tmp_path, "rb") as f:
uploaded_file = SimpleUploadedFile("test_multi_lantern.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)
lanterns = response.context["lanterns"]
lan_b1 = next(l for l in lanterns if "LAN01_B1" in l["code"])
lan_t2 = next(l for l in lanterns if "LAN02_T2" in l["code"])
# Verify that LAN01_B1 is paired with DB LAN02 (db_lan_car.id)
self.assertEqual(lan_b1["db_id"], db_lan_car.id)
self.assertEqual(lan_b1["status"], "modified")
self.assertTrue(any(f"Code : {db_lan_car.code} → {lan_b1['code']}" in d for d in lan_b1["details_diff"]))
# Verify that LAN02_T2 is paired with DB LAN01 (db_lan_tram.id)
self.assertEqual(lan_t2["db_id"], db_lan_tram.id)
self.assertEqual(lan_t2["status"], "modified")
self.assertTrue(any(f"Code : {db_lan_tram.code} → {lan_t2['code']}" in d for d in lan_t2["details_diff"]))
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_import_cross_plan_default_decisions_identical_vs_modified(self):
"""
Verify that:
- When pole & cable position and model are identical: status is 'identical',
default proposed decision in HTML is 'Ne rien faire (Identique)'.
- When pole position changed (DXF shift): status is 'modified',
default proposed decision in HTML is 'Mettre à jour l'asset'.
- When connected pole moved, the cable status becomes 'modified',
default proposed decision in HTML is 'Mettre à jour l'asset'.
"""
import openpyxl
import tempfile
from unittest.mock import patch
from django.contrib.gis.geos import Point as DjangoPoint, LineString as DjangoLineString
from assets.models import (
TrafficLightPole, TrafficLightCable,
TrafficLightPoleModel, TrafficLightCableModel
)
from django.core.files.uploadedfile import SimpleUploadedFile
pole_model = TrafficLightPoleModel.objects.first()
cable_model = TrafficLightCableModel.objects.first()
# Pole A01: position will be identical (DXF matches DB)
pole_a = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_A01",
name_fr="Poteau A01",
geom=DjangoPoint(149200.0, 170500.0, srid=3812),
lon=4.350,
lat=50.840,
model=pole_model,
status="active"
)
# Pole B01: position will change (DXF differs by 2.0m)
pole_b = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_B01",
name_fr="Poteau B01",
geom=DjangoPoint(149300.0, 170600.0, srid=3812),
lon=4.351,
lat=50.841,
model=pole_model,
status="active"
)
# Cable 01: connects A01 and B01
cable_01 = TrafficLightCable.objects.create(
intersection=self.intersection,
code="SWB01_CAB01",
name_fr="Câble 01",
geom=DjangoLineString([(149200.0, 170500.0), (149300.0, 170600.0)], srid=3812),
model=cable_model,
status="active"
)
# Create mock workbook
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=f"Câble 01 {cable_model.name_fr}")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="A01")
ws.cell(row=7, column=6, value="B01")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="A")
ws.cell(row=8, column=4, value="V")
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)
# Mock DXF poles: A01 identical (0.0m diff), B01 shifted by 2.0m
mock_dxf_poles = [
{'label': 'A01', 'x_3812': 149200.0, 'y_3812': 170500.0, 'lon': 4.350, 'lat': 50.840},
{'label': 'B01', 'x_3812': 149302.0, 'y_3812': 170600.0, 'lon': 4.35102, 'lat': 50.841},
]
try:
self.client.force_login(self.user)
with open(tmp_path, "rb") as f:
uploaded_file = SimpleUploadedFile("plan_test_decisions.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
with patch('assets.views.trafficlights.resolve_dxf_file_path', return_value='/dummy/test.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)
# Context assertions
poles = response.context["poles"]
cables = response.context["cables"]
p_a = next(p for p in poles if p["code"] == "SWB01_A01")
p_b = next(p for p in poles if p["code"] == "SWB01_B01")
c_1 = next(c for c in cables if c["code"] == "SWB01_CAB01")
# Pole A: Identical position & model
self.assertEqual(p_a["status"], "identical")
self.assertFalse(p_a["geom_modified"])
# Pole B: Position shifted
self.assertEqual(p_b["status"], "modified")
self.assertTrue(p_b["geom_modified"])
self.assertEqual(p_b["distance_diff"], 2.0)
# Cable 1: Connected pole B01 moved -> Cable position is modified!
self.assertEqual(c_1["status"], "modified")
self.assertTrue(c_1["geom_modified"])
self.assertTrue(any("déplacement de poteaux connectés" in d for d in c_1["details_diff"]))
# HTML rendered assertions
content = response.content.decode("utf-8")
# Pole A should have 'keep' (Ne rien faire) selected
self.assertIn('name="action_pole_SWB01_A01"', content)
self.assertRegex(content, r'<select name="action_pole_SWB01_A01"[^>]*>[\s\S]*?<option value="keep" selected>')
# Pole B should have 'modify' (Mettre à jour l'asset) selected
self.assertIn('name="action_pole_SWB01_B01"', content)
self.assertRegex(content, r'<select name="action_pole_SWB01_B01"[^>]*>[\s\S]*?<option value="modify" selected>')
# Cable 1 should have 'modify' (Mettre à jour l'asset) selected because B01 moved
self.assertIn('name="action_cable_SWB01_CAB01"', content)
self.assertRegex(content, r'<select name="action_cable_SWB01_CAB01"[^>]*>[\s\S]*?<option value="modify" selected>')
# Check that cable geometry cell rendered
self.assertIn('id="geom_cell_cable_SWB01_CAB01"', content)
# Test POST integrate with 'keep' on pole A01: verifies position and properties are untouched
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"],
"selected_pole_SWB01_A01": "on",
"action_pole_SWB01_A01": "keep",
"name_pole_SWB01_A01": "Ignored Custom Name",
"x_3812_pole_SWB01_A01": "999999.0",
"y_3812_pole_SWB01_A01": "999999.0",
})
self.assertEqual(response_integrate.status_code, 302)
pole_a.refresh_from_db()
# Must remain untouched because action was "keep" (Ne rien faire)
self.assertEqual(pole_a.name_fr, "Poteau A01")
self.assertEqual(pole_a.geom.x, 149200.0)
self.assertEqual(pole_a.geom.y, 170500.0)
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 +4852,94 @@ 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)
def test_build_conditional_filter_config_single_municipality_hidden(self):
from assets.views.core import build_conditional_filter_config
# Remove muni_b from contract
self.contract.municipalities.remove(self.muni_b)
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)
# When only 1 municipality is accessible, the filter should NOT be shown
self.assertIsNone(muni_filter)

View file

@ -24,6 +24,8 @@ class InspectionViewsTestCase(TestCase):
from common.models import UserConfig, UserThematics, Thematic
user_config, _ = UserConfig.objects.get_or_create(user=self.user)
user_config.is_intern = True
user_config.save()
t_parking, _ = Thematic.objects.get_or_create(code='parking', defaults={'name_fr': 'Stationnement'})
UserThematics.objects.update_or_create(user_config=user_config, thematic=t_parking, defaults={'can_inspect': True, 'can_view_assets': True})
self.client.force_login(self.user)
@ -37,7 +39,7 @@ class InspectionViewsTestCase(TestCase):
'standard_stand_count': 5
}
)
self.spot.geom = Polygon(((4.35, 50.85), (4.36, 50.85), (4.36, 50.86), (4.35, 50.86), (4.35, 50.85)), srid=4326)
self.spot.geom = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)), srid=3812)
self.spot.save()
self.spot_ct = ContentType.objects.get_for_model(ParkingSpot)
@ -52,7 +54,10 @@ class InspectionViewsTestCase(TestCase):
response = self.client.get(reverse('inspections:inspections_list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'PK-001')
response_mobile = self.client.get(reverse('mobile:inspections_list_mobile'))
self.assertEqual(response_mobile.status_code, 200)
self.assertContains(response_mobile, 'PK-001')
def test_quick_inspection_map_view(self):
response = self.client.get(reverse('inspections:quick_inspection_map'))
@ -162,7 +167,7 @@ class InspectionViewsTestCase(TestCase):
def test_inspections_geojson_view(self):
from django.contrib.gis.geos import Polygon
self.spot.geom = Polygon(((4.35, 50.85), (4.36, 50.85), (4.36, 50.86), (4.35, 50.86), (4.35, 50.85)), srid=4326)
self.spot.geom = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)), srid=3812)
self.spot.save()
AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete()
@ -198,7 +203,7 @@ class InspectionViewsTestCase(TestCase):
nature='visual'
)
response = self.client.get(reverse('inspections:inspections_list') + '?tab=all&q=SEARCH-99')
response = self.client.get(reverse('mobile:inspections_list_mobile') + '?tab=all&q=SEARCH-99')
self.assertEqual(response.status_code, 200)
self.assertIn(insp, response.context['inspections'])
@ -209,11 +214,13 @@ class InspectionViewsTestCase(TestCase):
user_custom = User.objects.create_user(username='user_custom_perm', password='pwd')
user_config, _ = UserConfig.objects.get_or_create(user=user_custom)
user_config.is_intern = True
user_config.save()
UserThematics.objects.create(user_config=user_config, thematic=t_parking, can_view_assets=True, can_inspect=False)
UserThematics.objects.create(user_config=user_config, thematic=t_lighting, can_view_assets=False, can_inspect=False)
self.client.force_login(user_custom)
resp = self.client.get(reverse('inspections:inspections_list'))
resp = self.client.get(reverse('mobile:inspections_list_mobile'))
self.assertEqual(resp.status_code, 200)
self.assertIn(t_parking, resp.context['thematics'])
self.assertNotIn(t_lighting, resp.context['thematics'])
@ -246,3 +253,54 @@ class InspectionViewsTestCase(TestCase):
self.assertEqual(data['total_count'], 1)
self.assertIn(insp, data['inspections'])
self.assertEqual(data['asset_pk'], self.spot.pk)
def test_asset_inspections_templatetag_renders_with_null_inspector(self):
"""Vérifie que le composant asset_inspections se rend sans erreur quand inspector est None."""
from assets.models import AssetInspection
from django.template import Template, Context
AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete()
insp = AssetInspection.objects.create(
asset=self.spot,
inspector=None,
result_status='pending',
nature='post_intervention'
)
request = self.client.get(reverse('inspections:inspections_list')).wsgi_request
t = Template('{% load asset_tags %}{% asset_inspections asset %}')
rendered = t.render(Context({'request': request, 'asset': self.spot}))
self.assertIn('assetInspectionDetailModal', rendered)
self.assertIn('-', rendered)
def test_roadway_detail_renders_with_inspection_without_inspector(self):
"""Vérifie que la page de détail d'une Roadway avec une inspection planifiée (sans inspecteur) ne plante pas (500)."""
from assets.models.roads import RoadStreet, Roadway
from common.models import UserThematics, Thematic, UserConfig
from inspections.models import Inspection
from django.contrib.gis.geos import MultiPolygon, Polygon
t_roads, _ = Thematic.objects.get_or_create(code='roads', defaults={'name_fr': 'Voiries'})
user_config = UserConfig.objects.get(user=self.user)
UserThematics.objects.update_or_create(user_config=user_config, thematic=t_roads, defaults={'can_view_assets': True})
street = RoadStreet.objects.create(code='WAT_R22_TEST_VIEW', name_fr='Chaussée Test')
p = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)))
roadway = Roadway.objects.create(
street=street,
geom=MultiPolygon(p, srid=3812)
)
# Créer une inspection ciblant la Roadway sans inspecteur (comme une inspection de durabilité automatique)
insp = Inspection.objects.create(
asset_content_type=ContentType.objects.get_for_model(Roadway),
asset_object_id=roadway.id,
inspector=None,
nature='post_intervention',
status='scheduled'
)
url = reverse('assets:roads_assets_detail', kwargs={'asset_model': 'roadway', 'asset_id': roadway.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

View file

@ -1,8 +1,49 @@
import openpyxl
import re
import os
import itertools
from collections import defaultdict
from django.conf import settings
def is_hp_or_ls(text):
"""
Checks if the wire/direction/circuit text refers to loudspeakers (HP/LS).
In cross plans, HP (haut-parleur) / LS (luidspreker) is often a common wire (fil commun)
and must NOT be treated as an equipment (neither lantern nor detector).
"""
if not text:
return False
t_str = str(text).strip()
t_lower = t_str.lower()
t_clean = re.sub(r'[\s/\\_\-\.:;]+', '', t_lower)
if t_clean in ('hp', 'ls', 'hpls', 'lshp', 'hpmute', 'lsmute', 'hautparleur', 'luidspreker'):
return True
if re.search(r'\b(?:hp\s*[/\\-]\s*ls|ls\s*[/\\-]\s*hp)\b', t_lower):
return True
if re.search(r'\b(?:hp|ls)\b', t_lower):
return True
if re.search(r'haut[\s-]*parleur|luidspreker', t_lower):
return True
return False
def is_cyclopieton_phase(phase):
"""
Checks if a phase code corresponds to a combined pedestrian + cyclist signal (cyclo-piéton).
Standard notation: lowercase letter (pedestrian) + 'F' or 'f' (fiets/cyclist), e.g. 'bF', 'aF', 'cF', 'dF', 'bF1',
or vice versa ('Fa', 'Fb').
"""
if not phase:
return False
ph = str(phase).strip()
if re.match(r'^[a-z][Ff]\d*$', ph) or re.match(r'^[Ff][a-z]\d*$', ph):
return True
if re.match(r'^[a-z]\s*[\+/&]\s*[Ff]\d*$', ph) or re.match(r'^[Ff]\s*[\+/&]\s*[a-z]\d*$', ph):
return True
ph_lower = ph.lower()
if 'cyclo' in ph_lower and ('piet' in ph_lower or 'piéto' in ph_lower or 'ped' in ph_lower):
return True
return False
def parse_cross_plan(excel_path):
"""
Parses a crossroads cross plan (kruisjesplan) Excel file and returns
@ -147,14 +188,21 @@ def parse_cross_plan(excel_path):
last_richting = None
continue
col2_val = sheet.cell(row=r, column=2).value if (richting_col and richting_col > 2) else None
# If the row is empty or contains section markers, skip it
if not val_kringen and not val_richting:
if not val_kringen and not val_richting and not col2_val:
# check if the entire row is empty
if all(sheet.cell(row=r, column=c).value is None for c in range(1, max_col + 1)):
continue
if val_richting:
# Check if HP/LS is in richting, kringen, or col 2
if is_hp_or_ls(val_richting) or is_hp_or_ls(col2_val) or is_hp_or_ls(val_kringen):
last_richting = "HP/LS"
elif val_richting:
last_richting = str(val_richting).strip()
elif col2_val and isinstance(col2_val, str) and not str(col2_val).strip().isdigit():
last_richting = str(col2_val).strip()
if not last_richting:
continue
@ -229,8 +277,9 @@ def parse_cross_plan(excel_path):
richting = conn["richting"]
kringen = conn["kringen"]
# Check if HP is present on this pole
if richting.lower() == "hp":
# Check if HP / LS (haut-parleur / luidspreker / fil commun) is present on this pole
# It must NOT be treated as an equipment (neither lantern nor detector).
if is_hp_or_ls(richting) or is_hp_or_ls(kringen):
pole_has_hp[p] = True
continue
@ -332,9 +381,9 @@ def parse_cross_plan(excel_path):
# Standard traffic signals (vehicles, trams, cycles, pedestrians)
# Richting codes are typically: A, B, C, T1, T2, F1, F2, a, b, c...
# Ignore wiring/cabling categories like ALIM, Radar, Com, contact, etc.
# Ignore wiring/cabling categories like ALIM, Radar, Com, contact, HP/LS, etc.
normalized = re.sub(r'\s+', ' ', richting).strip()
if re.search(r'(?:alim|com|contact|radar|tension|comm)', normalized, re.IGNORECASE):
if is_hp_or_ls(richting) or is_hp_or_ls(kringen) or re.search(r'(?:alim|com|contact|radar|tension|comm|\bhp\b|\bls\b|hp/ls|ls/hp)', normalized, re.IGNORECASE):
continue
if p not in pole_phases:
@ -344,9 +393,13 @@ def parse_cross_plan(excel_path):
# Now generate LANTERNS for each pole
deduced_lanterns = []
# helper to sort phases: vehicles first, then trams, then cycles, then pedestrians
# helper to sort phases: vehicles first, then trams, then cycles, then cyclo-piétons / pedestrians
def phase_sort_key(ph):
if ph and ph[0].islower(): # Pedestrian
if is_cyclopieton_phase(ph):
if ph and ph[0].islower():
return (3, ph)
return (2, ph)
elif ph and ph[0].islower(): # Pedestrian
return (3, ph)
elif ph.startswith("T"): # Tram
return (1, ph)
@ -373,7 +426,10 @@ def parse_cross_plan(excel_path):
if is_mast_arm and has_double_marker:
# Determine models for both lanterns
if phase and phase[0].islower(): # Pedestrian
if is_cyclopieton_phase(phase):
model_name_1 = "2V200 cyclo-piéto + HP" if has_hp else "2V200 cyclo-piéto"
model_name_2 = "2V200 cyclo-piéto + HP" if has_hp else "2V200 cyclo-piéto"
elif phase and phase[0].islower(): # Pedestrian
model_name_1 = "2V200 piéton+HP" if has_hp else "2V200"
model_name_2 = "2V200 piéton+HP" if has_hp else "2V200"
elif phase.startswith("T"): # Tram
@ -421,7 +477,12 @@ def parse_cross_plan(excel_path):
lantern_idx += 1
else:
# Deduce single model
if phase and phase[0].islower(): # Pedestrian
if is_cyclopieton_phase(phase):
if has_hp:
model_name = "2V200 cyclo-piéto + HP"
else:
model_name = "2V200 cyclo-piéto"
elif phase and phase[0].islower(): # Pedestrian
if has_hp:
model_name = "2V200 piéton+HP"
else:
@ -476,3 +537,313 @@ def parse_cross_plan(excel_path):
"lanterns": deduced_lanterns,
"detectors": deduced_detectors
}
def classify_lantern(model_name="", phase="", code="", name=""):
"""
Classifies a traffic light lantern based on its model name, phase, code, and description.
Returns:
category: 'tram', 'bus', 'pedestrian', 'cyclist', 'vehicle'
aspect_count: int (e.g. 1, 2, 3) or None
lens_size: int (e.g. 200, 300) or None
directions: set of strings in {'straight', 'left', 'right'}
"""
model_lower = (model_name or "").lower().strip()
name_lower = (name or "").lower().strip()
code_upper = (code or "").upper().strip()
phase_clean = (phase or "").strip()
combined_text = f"{model_lower} {name_lower}"
# 1. Category
# Check Tram first:
is_tram = (
"tram" in combined_text
or phase_clean.upper().startswith("T")
or bool(re.search(r'\bT\d+\b', code_upper))
)
# Check Bus:
is_bus = (
not is_tram and (
"bus" in combined_text
or phase_clean.upper().startswith("BUS")
or "transport en commun" in combined_text
)
)
# Check Cyclo-piéton:
is_cyclopieton = (
not is_tram and not is_bus and (
is_cyclopieton_phase(phase_clean)
or bool(re.search(r'[\b_]LAN\d+_[a-z][Ff]\d*', code_upper))
or "cyclo-piét" in combined_text
or "cyclo-piet" in combined_text
or "cyclopiet" in combined_text
or ("cyclo" in combined_text and any(w in combined_text for w in ["piét", "piet", "voetganger"]))
or (any(w in combined_text for w in ["cycliste", "velo", "vélo", "fiets"])
and any(w in combined_text for w in ["piéton", "pieton", "voetganger", "pedestrian"]))
)
)
# Check Cyclist:
is_cyclist = (
not is_tram and not is_bus and not is_cyclopieton and (
any(w in combined_text for w in ["cycliste", "cycl", "velo", "vélo", "fiets"])
or phase_clean.upper().startswith("F")
or bool(re.search(r'\bF\d+\b', code_upper))
)
)
# Check Pedestrian:
is_pedestrian = (
not is_tram and not is_bus and not is_cyclopieton and not is_cyclist and (
any(w in combined_text for w in ["piéton", "pieton", "voetganger", "pedestrian"])
or (bool(phase_clean) and phase_clean[0].islower() and not phase_clean.startswith("fl"))
or phase_clean.upper().startswith("P")
or bool(re.search(r'\bP\d+\b', code_upper))
)
)
if is_tram:
category = "tram"
elif is_bus:
category = "bus"
elif is_cyclopieton:
category = "cyclopieton"
elif is_cyclist:
category = "cyclist"
elif is_pedestrian:
category = "pedestrian"
else:
category = "vehicle"
# 2. Aspect count (e.g. 3V, 2V, 1V)
aspect_count = None
m_aspect = re.search(r'\b(\d+)V', f"{model_name} {name}", re.IGNORECASE)
if m_aspect:
aspect_count = int(m_aspect.group(1))
# 3. Lens size (e.g. 300, 200)
lens_size = None
m_size = re.search(r'\d+V(\d+)', f"{model_name} {name}", re.IGNORECASE)
if m_size:
lens_size = int(m_size.group(1))
else:
m_size_word = re.search(r'\b(200|300)\b', f"{model_name} {name}")
if m_size_word:
lens_size = int(m_size_word.group(1))
# 4. Directions: straight, left, right
directions = set()
if re.search(r'\b(tout\s+droit[es]?|rechtdoor|straight)\b', combined_text):
directions.add("straight")
if re.search(r'\b(fl\s*gauche|flèche\s*gauche|gauche|links|left)\b', combined_text):
directions.add("left")
if re.search(r'\b(fl\s*droite|flèche\s*droite|droite|rechts|right)\b', combined_text):
if not re.search(r'\btout\s+droite\b', combined_text):
directions.add("right")
return category, aspect_count, lens_size, directions
def extract_phase_from_lantern(code="", name=""):
"""
Extracts phase identifier (e.g. 'B1', 'T2', 'b', 'F1') from lantern code or name.
"""
code_str = code or ""
name_str = name or ""
if "_" in code_str:
parts = code_str.rsplit("_", 1)
if len(parts) == 2 and not parts[1].upper().startswith("LAN"):
return parts[1]
m = re.search(r'Phase\s+([A-Za-z0-9]+)', name_str, re.IGNORECASE)
if m:
return m.group(1)
return ""
def calculate_lantern_match_score(dl, el, is_single_on_pole=False):
"""
Computes a match compatibility score between a deduced plan lantern (dl)
and an existing DB lantern (el).
Higher score indicates a more likely match. Negative score indicates a conflict.
"""
dl_code = dl.get("code", "")
el_code = getattr(el, "code", "") or (el.get("code", "") if isinstance(el, dict) else "")
# 1. Exact full code match
if dl_code and el_code and dl_code == el_code:
return 10000
dl_base_code = dl_code.rsplit("_", 1)[0] if "_" in dl_code else dl_code
el_base_code = el_code.rsplit("_", 1)[0] if "_" in el_code else el_code
is_prefix_code_match = bool(
(dl_base_code and el_code and dl_base_code == el_code)
or (dl_code and el_base_code and dl_code == el_base_code)
or (dl_base_code and el_base_code and dl_base_code == el_base_code)
)
if is_single_on_pole and is_prefix_code_match:
return 1000
dl_model_name = dl.get("model_name", "")
dl_phase = dl.get("phase", "") or extract_phase_from_lantern(dl_code, dl.get("name", ""))
dl_name = dl.get("name", "")
el_name = getattr(el, "name_fr", "") or getattr(el, "name", "") or (el.get("name_fr", "") if isinstance(el, dict) else "")
el_model = getattr(el, "model", None)
if el_model:
el_model_name = getattr(el_model, "name_fr", "") or ""
el_model_name_nl = getattr(el_model, "name_nl", "") or ""
elif isinstance(el, dict):
el_model_name = el.get("model_name", "")
el_model_name_nl = el.get("model_name_nl", "")
else:
el_model_name = ""
el_model_name_nl = ""
el_phase = extract_phase_from_lantern(el_code, el_name)
dl_cat, dl_aspect, dl_size, dl_dirs = classify_lantern(dl_model_name, dl_phase, dl_code, dl_name)
el_cat, el_aspect, el_size, el_dirs = classify_lantern(el_model_name, el_phase, el_code, el_name)
score = 0
# 2. Category matching
if dl_cat == el_cat:
if dl_cat in ("tram", "bus", "pedestrian", "cyclist", "cyclopieton"):
score += 300
else:
score += 200
elif {dl_cat, el_cat} in ({"cyclopieton", "pedestrian"}, {"cyclopieton", "cyclist"}):
score += 50
else:
# Category mismatch penalty: e.g. tram vs car, pedestrian vs car
score -= 600
# 3. Phase matching
if dl_phase and el_phase:
if dl_phase.upper() == el_phase.upper():
score += 500
else:
score -= 300
# 4. Model exact match
if dl_model_name and el_model_name:
if dl_model_name.lower().strip() == el_model_name.lower().strip():
score += 150
elif el_model_name_nl and dl_model_name.lower().strip() == el_model_name_nl.lower().strip():
score += 150
# 5. Aspect count matching (3V vs 3V, 2V vs 2V)
if dl_aspect and el_aspect:
if dl_aspect == el_aspect:
score += 50
else:
score -= 100
# 6. Lens size matching (300 vs 300, 200 vs 200)
if dl_size and el_size:
if dl_size == el_size:
score += 30
# 7. Directional matching (straight, left, right)
if dl_dirs and el_dirs:
if dl_dirs == el_dirs:
score += 80
elif dl_dirs.intersection(el_dirs):
score += 40
else:
score -= 60
# 8. Prefix match bonus (e.g. LAN03 vs LAN03_bF on the same pole)
if is_prefix_code_match:
score += 250
# 9. LAN index tie-breaker (e.g. LAN01 vs LAN01)
m_dl = re.search(r'LAN(\d+)', dl_code, re.IGNORECASE)
m_el = re.search(r'LAN(\d+)', el_code, re.IGNORECASE)
if m_dl and m_el and m_dl.group(1) == m_el.group(1):
score += 15
# 10. Single lantern on pole baseline bonus
if is_single_on_pole:
score += 200
return score
def match_pole_lanterns(pole_dls, pole_els):
"""
Given a list of deduced lanterns (pole_dls) and DB lanterns (pole_els)
on the same pole, finds the optimal 1-to-1 matching based on scoring.
Returns:
matched_pairs: list of (dl, el) in the original order of pole_dls
unmatched_dls: list of dl (new lanterns)
unmatched_els: list of el (db_only lanterns)
"""
if not pole_dls:
return [], [], list(pole_els)
if not pole_els:
return [], list(pole_dls), []
is_single_on_pole = (len(pole_dls) == 1 and len(pole_els) == 1)
scores = {}
for i, dl in enumerate(pole_dls):
for j, el in enumerate(pole_els):
scores[(i, j)] = calculate_lantern_match_score(dl, el, is_single_on_pole=is_single_on_pole)
m = len(pole_dls)
n = len(pole_els)
best_assignment = []
best_total_score = -float('inf')
if min(m, n) <= 8:
if m <= n:
for p in itertools.permutations(range(n), m):
total = sum(scores[(i, p[i])] for i in range(m))
if total > best_total_score:
best_total_score = total
best_assignment = [(i, p[i]) for i in range(m)]
else:
for p in itertools.permutations(range(m), n):
total = sum(scores[(p[j], j)] for j in range(n))
if total > best_total_score:
best_total_score = total
best_assignment = [(p[j], j) for j in range(n)]
else:
available_dls = set(range(m))
available_els = set(range(n))
sorted_pairs = sorted(scores.items(), key=lambda item: item[1], reverse=True)
best_assignment = []
for (i, j), score in sorted_pairs:
if i in available_dls and j in available_els:
best_assignment.append((i, j))
available_dls.remove(i)
available_els.remove(j)
matched_pairs = []
matched_dl_indices = set()
matched_el_indices = set()
for i, j in best_assignment:
if scores[(i, j)] >= 0:
matched_pairs.append((pole_dls[i], pole_els[j]))
matched_dl_indices.add(i)
matched_el_indices.add(j)
# Sort matched pairs to preserve the original order of pole_dls
matched_pairs.sort(key=lambda pair: pole_dls.index(pair[0]))
unmatched_dls = [dl for i, dl in enumerate(pole_dls) if i not in matched_dl_indices]
unmatched_els = [el for j, el in enumerate(pole_els) if j not in matched_el_indices]
return matched_pairs, unmatched_dls, unmatched_els

View file

@ -65,13 +65,9 @@ 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)
)
municipality_choices = [(m.id, m.name) for m in municipalities]
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] if len(municipalities) > 1 else []
# Status choices for filters
status_choices = [

View file

@ -19,6 +19,19 @@ from assets.models.buildings import (
)
USAGE_TYPE_COLORS = {
'administrative': '#2563EB', # Bleu cobalt
'education': '#D97706', # Ambre doré
'sports': '#059669', # Vert émeraude
'culture': '#7C3AED', # Violet pourpre
'social': '#E11D48', # Rose framboise
'technical': '#64748B', # Gris ardoise
'housing': '#0D9488', # Sarcelle
'other': '#475569', # Ardoise foncée
}
DEFAULT_BUILDING_COLOR = '#2563EB'
def publicbuildings_locations_geojson(request):
"""
Vue GeoJSON renvoyant la collection des localisations de bâtiments publics pour la carte unifiée.
@ -131,10 +144,13 @@ def publicbuildings_locations_geojson(request):
"is_erp": b.is_erp,
"erp_category": b.erp_category or '',
"dpe_rating": b.dpe_rating or '',
"address": b.address or '',
"surface_m2": b.surface_m2,
"total_surface_m2": b.total_surface_m2,
"lon": b.lon,
"lat": b.lat,
"color": "#2B579A",
"display_color": "#2B579A",
"color": USAGE_TYPE_COLORS.get(b.usage_type, DEFAULT_BUILDING_COLOR),
"display_color": USAGE_TYPE_COLORS.get(b.usage_type, DEFAULT_BUILDING_COLOR),
"asset_type": "publicbuilding",
"detail_url": detail_url,
"element_type": get_element_type_label("publicbuilding"),
@ -165,12 +181,16 @@ def publicbuildings_assets_geojson(request):
asset_types = data.get('asset_type') or []
if isinstance(asset_types, str):
asset_types = [asset_types]
usage_types = data.get('usage_type') or []
if isinstance(usage_types, str):
usage_types = [usage_types]
search_q = (data.get('q') or '').strip()
else:
statuses = request.GET.getlist('asset_status')
categories = request.GET.getlist('category')
asset_types = request.GET.getlist('asset_type', [])
usage_types = request.GET.getlist('usage_type', [])
search_q = request.GET.get('q', '').strip()
qs = BuildingAsset.objects.select_related('building', 'location', 'parent_asset', 'category').all()
@ -181,6 +201,9 @@ def publicbuildings_assets_geojson(request):
if asset_types:
qs = qs.filter(asset_type__in=asset_types)
if usage_types:
qs = qs.filter(building__usage_type__in=usage_types)
if statuses:
qs = qs.filter(status__in=statuses)

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
@ -654,7 +654,7 @@ def build_conditional_filter_config(accessible_thematics, user=None):
'name': pt.get_name(),
'group': group_key,
'group_label': group_label,
'image_url': None,
'image_url': pt.sgv_picture.url if getattr(pt, 'sgv_picture', None) else None,
})
controller_maintenance_provider_options = []
@ -673,7 +673,8 @@ def build_conditional_filter_config(accessible_thematics, user=None):
for contract in contracts
]
# Roads / public lighting / sign / artworks: municipality on locations
# Roads / public lighting / sign / artworks: municipality on locations (only if more than 1 option)
if len(municipality_options) > 1:
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']:
if thematic_code in accessible_codes:
config.setdefault(thematic_code, []).append({
@ -788,8 +789,22 @@ def build_conditional_filter_config(accessible_thematics, user=None):
# Public buildings specific choices
from assets.models.buildings import USAGE_TYPE_CHOICES
building_usage_icons = {
'administrative': 'bi-building',
'education': 'bi-mortarboard',
'sports': 'bi-trophy',
'culture': 'bi-palette',
'social': 'bi-people',
'technical': 'bi-tools',
'housing': 'bi-house-door',
'other': 'bi-building-gear',
}
building_usage_options = [
{'value': str(key), 'label': str(value)}
{
'value': str(key),
'label': str(value),
'icon': building_usage_icons.get(str(key), 'bi-building'),
}
for key, value in USAGE_TYPE_CHOICES
]
@ -1073,6 +1088,14 @@ def unified_assets_index(request):
{'id': c.id, 'name': c.get_name()} for c in cats
]
# Si des thématiques sont spécifiées dans l'URL mais sans catégories explicites,
# activer l'ensemble des catégories de ces thématiques par défaut pour charger les assets
if has_url_thematics and not selected_categories:
selected_categories = []
for t_code in selected_thematics:
for cat in thematic_categories.get(t_code, []):
selected_categories.append(f"{t_code}::{cat['name']}")
link_assets_url = (
reverse(
'interventions:intervention_preventive_link_elements',

View file

@ -339,6 +339,13 @@ def nature_assets_geojson(request):
def obj_to_feature(obj, asset_type=None):
geometry, lon, lat = build_geometry_and_coords(obj)
detail_url = reverse('assets:nature_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) if asset_type else None
model_name = obj.get('model_name') or ''
code = obj.get('code') or ''
is_planter = asset_type == 'naturebench' and (
any(w in model_name.lower() for w in ('bac', 'jardin', 'bloem', 'plante', 'fleur'))
or code.startswith('CSE-BAC')
)
element_type_label = _('Bac à fleurs') if is_planter else get_element_type_label(asset_type or '')
return {
"type": "Feature",
"id": obj['id'],
@ -355,11 +362,12 @@ def nature_assets_geojson(request):
"lon": lon,
"lat": lat,
"asset_type": asset_type,
"asset_sub_type": "planter" if is_planter else ("bench" if asset_type == "naturebench" else ""),
"area": obj.get('surface_area', ''),
"color": obj['color'] if obj['color'] else '#ccc',
"detail_url": detail_url,
"bulb_types": obj.get('bulb_types') or [],
"element_type": get_element_type_label(asset_type or ''),
"element_type": element_type_label,
"thematic": "nature",
"is_location": False,
"structure_type": obj.get('structure_type', ''),

View file

@ -22,7 +22,7 @@ MAX_ASSET_FEATURES = 5000
@login_required
def parking_index(request):
"""Page d'accueil de la thématique Stationnement & Mobilité."""
"""Page d'accueil de la thématique Stationnement."""
check_thematic_access(request.user, 'parking')
parking_thematic = Thematic.objects.filter(code='parking').first()

View file

@ -62,9 +62,10 @@ 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]
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
categories = list(AssetCategory.objects.filter(thematic__code = 'publiclighting'))
categories.sort(key=lambda m: m.get_name().lower())

View file

@ -65,9 +65,10 @@ 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]
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
categories = list(AssetCategory.objects.filter(thematic__code = 'roads'))
categories.sort(key=lambda m: m.get_name().lower())
@ -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
# --------- 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
@ -289,10 +305,11 @@ def roads_assets_geojson(request):
"geometry": geometry,
"properties": {
"code": obj.code,
"name": obj.code,
"name": obj.get_name() if hasattr(obj, "get_name") else (getattr(obj, "name_fr", None) or obj.code),
"category": obj.category.get_name() if obj.category else None,
"model": model.get_name() if model else None,
"quality_level": obj.get_quality_level_display() if hasattr(obj, 'get_quality_level_display') else None,
"quality_level_value": getattr(obj, 'quality_level', None),
"status": obj.status,
"validation_status": obj.validation_status,
"lon": lon,
@ -350,7 +367,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()
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,9 +70,10 @@ 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]
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
categories = list(AssetCategory.objects.filter(thematic__code = 'sign'))
categories.sort(key=lambda m: m.get_name().lower())

View file

@ -61,7 +61,7 @@ def structures_index(request):
selected_locations_ids = draft_data.get('locations', [])
user_config, _ = UserConfig.objects.get_or_create(user=request.user)
user_config, _created = UserConfig.objects.get_or_create(user=request.user)
thematic_choices_qs = (
UserThematics.objects
.filter(user_config=user_config, can_view_assets=True)

View file

@ -26,6 +26,7 @@ from django.utils.translation import get_language, gettext as _
from django.utils.timezone import make_aware, get_current_timezone
import json
import re
from datetime import datetime
import unicodedata
from collections import defaultdict
@ -1922,6 +1923,54 @@ def get_base_dxf_name(filename):
return f"{base_cleaned}{ext}".lower()
def resolve_dxf_file_path(intersection, filename):
if not filename:
return None
filename_clean = os.path.basename(filename)
# 1. Managed documents
try:
from documents.models import DocumentAttachment, DocumentVersion
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(intersection)
doc_ids = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id).values_list('document_id', flat=True)
versions = DocumentVersion.objects.filter(document_id__in=doc_ids, file__icontains=filename_clean)
for v in versions:
if v.file and os.path.exists(v.file.path):
return v.file.path
except Exception:
pass
# 2. Database documents (legacy)
try:
for d in intersection.documents.all():
if os.path.basename(d.file.name) == filename_clean and os.path.exists(d.file.path):
return d.file.path
except Exception:
pass
# 3. Private media dxf_files
uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files')
p = os.path.join(uploaded_dxf_dir, filename_clean)
if os.path.exists(p):
return p
# 4. External read-only files
read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
p = os.path.join(read_only_dxf_dir, filename_clean)
if os.path.exists(p):
return p
# 5. Dxf temp
dxf_temp_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_temp')
p = os.path.join(dxf_temp_dir, filename_clean)
if os.path.exists(p):
return p
return None
@check_thematic_access('trafficlights', edit_permission_required=True)
def import_dxf(request, intersection_id):
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
@ -3696,6 +3745,77 @@ def update_staged_pole_position(request, intersection_id, pole_id):
})
def find_db_lantern_model(model_name):
"""
Finds the most suitable TrafficLightLanternModel from the database based on model_name.
Gracefully handles variants such as '2V200 cyclo-piéto + HP', '2V200 cyclo-piéton + HP',
'2V200 piéton+HP', tram, cyclist models, etc.
"""
if not model_name:
return TrafficLightLanternModel.objects.first()
m = TrafficLightLanternModel.objects.filter(name_fr__iexact=model_name).first()
if m:
return m
m_nl = TrafficLightLanternModel.objects.filter(name_nl__iexact=model_name).first()
if m_nl:
return m_nl
model_lower = str(model_name).lower()
if "cyclo" in model_lower:
qs = TrafficLightLanternModel.objects.filter(
Q(name_fr__icontains="cyclo") | Q(name_nl__icontains="cyclo") |
Q(name_fr__icontains="fietsers- en voetgangers") | Q(name_nl__icontains="fietsers- en voetgangers")
)
if "hp" in model_lower or "haut-parleur" in model_lower:
m = qs.filter(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
else:
m = qs.exclude(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
if m:
return m
if qs.exists():
return qs.first()
elif any(w in model_lower for w in ["piét", "piet", "voetganger"]):
qs = TrafficLightLanternModel.objects.filter(
Q(name_fr__icontains="piét") | Q(name_fr__icontains="piet") | Q(name_nl__icontains="voetganger")
).exclude(Q(name_fr__icontains="cyclo") | Q(name_nl__icontains="cyclo"))
if "hp" in model_lower or "haut-parleur" in model_lower:
m = qs.filter(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
else:
m = qs.exclude(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
if m:
return m
elif "tram" in model_lower:
if "300" in model_lower:
m = TrafficLightLanternModel.objects.filter(name_fr__icontains="tram").filter(name_fr__icontains="300").first()
if m:
return m
m = TrafficLightLanternModel.objects.filter(name_fr__icontains="tram").first()
if m:
return m
elif any(w in model_lower for w in ["cycliste", "cycl", "fiets"]):
m = TrafficLightLanternModel.objects.filter(
Q(name_fr__icontains="cycl") | Q(name_nl__icontains="fiets")
).exclude(Q(name_fr__icontains="piét") | Q(name_fr__icontains="piet") | Q(name_nl__icontains="voetganger")).first()
if m:
return m
return TrafficLightLanternModel.objects.first()
def is_stib_detector(detector_obj, code=""):
"""
Checks if a detector is a STIB/tram detection loop (e.g. dT1, dT2, DT1),
which is provided/managed by STIB and not present in cross plans.
"""
name = (getattr(detector_obj, "name", None) or getattr(detector_obj, "name_fr", None) or "").strip()
code_str = (code or getattr(detector_obj, "code", "") or "").strip()
if re.match(r'^dt[\s_\d]*$', name, re.IGNORECASE) or name.lower().startswith("dt"):
return True
if re.search(r'\bdt\d+\b', name, re.IGNORECASE) or "stib" in name.lower():
return True
if re.search(r'(?:^|_)dt\d*(?:_|$)', code_str, re.IGNORECASE) or "stib" in code_str.lower():
return True
return False
@check_thematic_access('trafficlights', edit_permission_required=True)
def import_cross_plan(request, intersection_id):
from django.shortcuts import render, get_object_or_404, redirect
@ -3704,7 +3824,7 @@ def import_cross_plan(request, intersection_id):
from django.urls import reverse
from django.conf import settings
import json
from assets.utils.cross_plan_parser import parse_cross_plan
from assets.utils.cross_plan_parser import parse_cross_plan, match_pole_lanterns
from assets.models.trafficlights import (
TrafficLightIntersection, TrafficLightPole, TrafficLightLantern,
TrafficLightDetector, TrafficLightCable, TrafficLightPoleModel,
@ -4068,6 +4188,7 @@ def import_cross_plan(request, intersection_id):
"properties": {
"id": ep.id,
"code": ep.code,
"short_code": ep.code.split('_')[-1] if '_' in ep.code else ep.code,
"model": ep.model.code if ep.model else ""
}
})
@ -4078,43 +4199,142 @@ def import_cross_plan(request, intersection_id):
deduced = merged_deduced
# ── RESOLVE ACTIVE DXF & SNAP POINTS ──
active_dxf_file = request.POST.get('selected_dxf') or (dxf_files[0] if dxf_files else "")
active_dxf_poles = []
dxf_poles_by_label = {}
if active_dxf_file:
dxf_path = resolve_dxf_file_path(intersection, active_dxf_file)
if dxf_path:
try:
active_dxf_poles = parse_dxf_poles(dxf_path)
for dp_dxf in active_dxf_poles:
lbl = (dp_dxf.get('label') or "").strip().upper()
if lbl:
dxf_poles_by_label[lbl] = dp_dxf
except Exception as e:
logger.warning("Could not parse DXF poles from %s: %s", dxf_path, e)
# ── 1. POLES COMPARISON ──
existing_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')}
compared_poles = []
from pyproj import Transformer
transformer_3812_to_4326 = Transformer.from_crs(3812, 4326, always_xy=True)
transformer_4326_to_3812 = Transformer.from_crs(4326, 3812, always_xy=True)
for dp in deduced["poles"]:
code = dp["code"]
short_code = dp["short_code"]
model_name = dp["model"]
db_model = TrafficLightPoleModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightPoleModel.objects.first()
dxf_match = dxf_poles_by_label.get(short_code.strip().upper())
dxf_lon = dxf_match['lon'] if dxf_match else None
dxf_lat = dxf_match['lat'] if dxf_match else None
dxf_x_3812 = dxf_match['x_3812'] if dxf_match else None
dxf_y_3812 = dxf_match['y_3812'] if dxf_match else None
if code in existing_poles:
ep = existing_poles[code]
# Since cross plans do not specify pole physical models (height, color), keep existing DB model
status = 'identical'
ep_lon = ep.lon
ep_lat = ep.lat
ep_x_3812 = ep.geom.x if ep.geom else None
ep_y_3812 = ep.geom.y if ep.geom else None
if ep.geom and (ep_lon is None or ep_lat is None):
try:
ep_lon, ep_lat = transformer_3812_to_4326.transform(ep_x_3812, ep_y_3812)
except Exception:
pass
elif ep.geom is None and ep_lon is not None and ep_lat is not None:
try:
ep_x_3812, ep_y_3812 = transformer_4326_to_3812.transform(ep_lon, ep_lat)
except Exception:
pass
details_diff = []
geom_modified = False
distance_diff = None
if dxf_match:
if ep_x_3812 is not None and ep_y_3812 is not None:
import math
dist = math.sqrt((ep_x_3812 - dxf_x_3812)**2 + (ep_y_3812 - dxf_y_3812)**2)
if dist >= 0.05:
geom_modified = True
distance_diff = round(dist, 1)
details_diff.append(_("Position modifiée (DXF) : décalage de %(dist).1f m") % {'dist': dist})
else:
geom_modified = True
details_diff.append(_("Nouvelle position détectée (DXF)"))
is_model_diff = (ep.model_id != db_model.id) if (ep.model and db_model) else False
if is_model_diff:
details_diff.append(_("Modèle : %(db_m)s → %(new_m)s") % {'db_m': ep.model.name_fr if ep.model else 'Aucun', 'new_m': model_name})
status = 'modified' if (geom_modified or is_model_diff) else 'identical'
compared_poles.append({
'code': code,
'short_code': dp['short_code'],
'name': ep.name_fr or dp.get('name', dp['short_code']),
'short_code': short_code,
'name': ep.name_fr or dp.get('name', short_code),
'status': status,
'deduced_model_name': ep.model.name_fr if ep.model else model_name,
'deduced_model_name': model_name,
'db_model_name': ep.model.name_fr if ep.model else '',
'db_id': ep.id,
'details_diff': []
'details_diff': details_diff,
'geom_modified': geom_modified,
'distance_diff': distance_diff,
'db_lon': ep_lon,
'db_lat': ep_lat,
'db_x_3812': ep_x_3812,
'db_y_3812': ep_y_3812,
'new_lon': dxf_lon if dxf_match else ep_lon,
'new_lat': dxf_lat if dxf_match else ep_lat,
'new_x_3812': dxf_x_3812 if dxf_match else ep_x_3812,
'new_y_3812': dxf_y_3812 if dxf_match else ep_y_3812,
})
# remove from processed
existing_poles.pop(code)
else:
details_diff = []
if dxf_match:
details_diff.append(_("Positionné via DXF"))
compared_poles.append({
'code': code,
'short_code': dp['short_code'],
'name': dp.get('name', dp['short_code']),
'short_code': short_code,
'name': dp.get('name', short_code),
'status': 'new',
'deduced_model_name': model_name,
'db_model_name': '',
'db_id': None,
'details_diff': []
'details_diff': details_diff,
'geom_modified': False,
'distance_diff': None,
'db_lon': None,
'db_lat': None,
'db_x_3812': None,
'db_y_3812': None,
'new_lon': dxf_lon,
'new_lat': dxf_lat,
'new_x_3812': dxf_x_3812,
'new_y_3812': dxf_y_3812,
})
# Add remaining existing active poles (orphans)
for code, ep in existing_poles.items():
ep_lon = ep.lon
ep_lat = ep.lat
ep_x_3812 = ep.geom.x if ep.geom else None
ep_y_3812 = ep.geom.y if ep.geom else None
if ep.geom and (ep_lon is None or ep_lat is None):
try:
ep_lon, ep_lat = transformer_3812_to_4326.transform(ep_x_3812, ep_y_3812)
except Exception:
pass
elif ep.geom is None and ep_lon is not None and ep_lat is not None:
try:
ep_x_3812, ep_y_3812 = transformer_4326_to_3812.transform(ep_lon, ep_lat)
except Exception:
pass
compared_poles.append({
'code': code,
'short_code': code.split('_')[-1] if '_' in code else code,
@ -4123,21 +4343,65 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': '',
'db_model_name': ep.model.name_fr if ep.model else '',
'db_id': ep.id,
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
'details_diff': [_("Absent du fichier Excel (présent en DB)")],
'geom_modified': False,
'distance_diff': None,
'db_lon': ep_lon,
'db_lat': ep_lat,
'db_x_3812': ep_x_3812,
'db_y_3812': ep_y_3812,
'new_lon': ep_lon,
'new_lat': ep_lat,
'new_x_3812': ep_x_3812,
'new_y_3812': ep_y_3812,
})
# ── 2. CABLES COMPARISON ──
existing_cables = {c.code: c for c in TrafficLightCable.objects.filter(intersection=intersection, status='active')}
compared_cables = []
# Track poles that moved or are newly placed to see if connected cable paths changed
modified_pole_codes = {
p['code'] for p in compared_poles
if p.get('geom_modified') or p.get('status') == 'new'
}
modified_pole_short_codes = {
p['short_code'] for p in compared_poles
if p.get('geom_modified') or p.get('status') == 'new'
}
for dc in deduced["cables"]:
code = dc["code"]
model_name = dc["model_name"]
db_model = TrafficLightCableModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightCableModel.objects.first()
connected = dc.get("connected_poles", [])
has_connected_moved = any(
p_code in modified_pole_codes or p_code.split('_')[-1] in modified_pole_short_codes
for p_code in connected
)
if code in existing_cables:
ec = existing_cables[code]
is_diff = ec.model_id != db_model.id
status = 'modified' if is_diff else 'identical'
has_geom = ec.geom is not None
is_diff = (ec.model_id != db_model.id) if (ec.model and db_model) else False
details_diff = []
if is_diff:
details_diff.append(_("Modèle : %(db_m)s → %(new_m)s") % {
'db_m': ec.model.name_fr if ec.model else 'Aucun',
'new_m': model_name
})
geom_modified = False
if has_connected_moved:
geom_modified = True
details_diff.append(_("Position modifiée : déplacement de poteaux connectés"))
elif not has_geom and len(connected) >= 2:
geom_modified = True
details_diff.append(_("Nouveau tracé déduit des poteaux"))
status = 'modified' if (is_diff or geom_modified) else 'identical'
compared_cables.append({
'code': code,
'name': ec.name_fr or dc['name'],
@ -4145,11 +4409,17 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': model_name,
'db_model_name': ec.model.name_fr if ec.model else '',
'db_id': ec.id,
'details_diff': [f"Modèle : {ec.model.name_fr if ec.model else 'Aucun'} → {model_name}"] if is_diff else [],
'connected_poles': dc.get("connected_poles", [])
'details_diff': details_diff,
'connected_poles': connected,
'has_db_geom': has_geom,
'geom_modified': geom_modified,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
})
existing_cables.pop(code)
else:
details_diff = []
if len(connected) >= 2:
details_diff.append(_("Tracé déduit des poteaux"))
compared_cables.append({
'code': code,
'name': dc['name'],
@ -4157,10 +4427,14 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': model_name,
'db_model_name': '',
'db_id': None,
'details_diff': [],
'connected_poles': dc.get("connected_poles", [])
'details_diff': details_diff,
'connected_poles': connected,
'has_db_geom': False,
'geom_modified': False,
'db_geom_length': None,
})
for code, ec in existing_cables.items():
has_geom = ec.geom is not None
compared_cables.append({
'code': code,
'name': ec.name_fr or code,
@ -4168,49 +4442,80 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': '',
'db_model_name': ec.model.name_fr if ec.model else '',
'db_id': ec.id,
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
'details_diff': [_("Absent du fichier Excel (présent en DB)")],
'has_db_geom': has_geom,
'geom_modified': False,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
})
# ── 3. LANTERNS COMPARISON ──
existing_lanterns = {l.code: l for l in TrafficLightLantern.objects.filter(pole__intersection=intersection, status='active')}
compared_lanterns = []
all_db_lanterns = list(TrafficLightLantern.objects.filter(pole__intersection=intersection, status='active'))
from collections import defaultdict
deduced_by_pole = defaultdict(list)
pole_order = []
for dl in deduced["lanterns"]:
p_code = dl["pole_code"]
if p_code not in deduced_by_pole:
pole_order.append(p_code)
deduced_by_pole[p_code].append(dl)
db_by_pole = defaultdict(list)
for el in all_db_lanterns:
p_code = el.pole.code if el.pole else ""
db_by_pole[p_code].append(el)
def find_db_pole_key(target_pole_code, db_dict):
if target_pole_code in db_dict:
return target_pole_code
target_short = target_pole_code.split('_')[-1]
for k in db_dict:
if k.split('_')[-1] == target_short:
return k
return None
compared_lanterns = []
for p_code in pole_order:
pole_dls = deduced_by_pole[p_code]
db_key = find_db_pole_key(p_code, db_by_pole)
pole_els = db_by_pole.pop(db_key, []) if db_key else []
matched_pairs, unmatched_dls, unmatched_els = match_pole_lanterns(pole_dls, pole_els)
for dl, el in matched_pairs:
code = dl["code"]
base_code = code.rsplit('_', 1)[0] if '_' in code else code
model_name = dl["model_name"]
db_model = TrafficLightLanternModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightLanternModel.objects.first()
db_model = find_db_lantern_model(model_name)
# Try exact match, then try prefix match (without the phase suffix)
match_key = None
if code in existing_lanterns:
match_key = code
elif base_code in existing_lanterns:
match_key = base_code
if match_key:
el = existing_lanterns[match_key]
is_rename = (el.code != code)
is_model_diff = el.model_id != db_model.id
is_model_diff = el.model_id != (db_model.id if db_model else None)
status = 'modified' if (is_rename or is_model_diff) else 'identical'
details = []
if is_rename:
details.append(f"Code : {el.code} → {code}")
if is_model_diff:
details.append(f"Modèle : {el.model.name_fr if el.model else 'Aucun'} → {model_name}")
details.append(f"Modèle : {el.model.name_fr if el.model else 'Aucun'} → {db_model.name_fr if db_model else model_name}")
if is_model_diff and el.name_fr and el.model and el.name_fr.strip().lower() == el.model.name_fr.strip().lower():
display_name = dl['name']
else:
display_name = el.name_fr or dl['name']
compared_lanterns.append({
'code': code,
'name': el.name_fr or dl['name'],
'name': display_name,
'status': status,
'deduced_model_name': model_name,
'deduced_model_name': db_model.name_fr if db_model else model_name,
'db_model_name': el.model.name_fr if el.model else '',
'db_id': el.id,
'pole_code': dl['pole_code'],
'details_diff': details
})
existing_lanterns.pop(match_key)
else:
for dl in unmatched_dls:
code = dl["code"]
model_name = dl["model_name"]
compared_lanterns.append({
'code': code,
'name': dl['name'],
@ -4221,15 +4526,30 @@ def import_cross_plan(request, intersection_id):
'pole_code': dl['pole_code'],
'details_diff': []
})
for code, el in existing_lanterns.items():
for el in unmatched_els:
compared_lanterns.append({
'code': code,
'name': el.name_fr or code,
'code': el.code,
'name': el.name_fr or el.code,
'status': 'db_only',
'deduced_model_name': '',
'db_model_name': el.model.name_fr if el.model else '',
'db_id': el.id,
'pole_code': el.pole.code,
'pole_code': el.pole.code if el.pole else p_code,
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
})
# Remaining DB lanterns on poles not present in the plan at all
for p_key, remaining_els in db_by_pole.items():
for el in remaining_els:
compared_lanterns.append({
'code': el.code,
'name': el.name_fr or el.code,
'status': 'db_only',
'deduced_model_name': '',
'db_model_name': el.model.name_fr if el.model else '',
'db_id': el.id,
'pole_code': el.pole.code if el.pole else p_key,
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
})
@ -4284,6 +4604,9 @@ def import_cross_plan(request, intersection_id):
'details_diff': []
})
for code, ed in existing_detectors.items():
is_stib = is_stib_detector(ed, code)
action_default = 'keep' if is_stib else 'archive'
diff_text = _("Détection STIB / Tram (fournie par la STIB, conservée par défaut)") if is_stib else _("Absent du fichier Excel (présent en DB)")
compared_detectors.append({
'code': code,
'name': ed.name or code,
@ -4292,7 +4615,9 @@ def import_cross_plan(request, intersection_id):
'db_model_name': ed.model.name_fr if ed.model else '',
'db_id': ed.id,
'pole_code': ed.pole.code if ed.pole else '',
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
'details_diff': [diff_text],
'action_default': action_default,
'is_stib': is_stib,
})
# ── 5. OTHER ASSOCIATED ASSETS (NOT IN CROSS PLAN) ──
@ -4526,8 +4851,8 @@ def import_cross_plan(request, intersection_id):
'cable_models': cable_models,
'step': 'preview',
'thematic_code': 'trafficlights',
'dxf_file': "",
'dxf_poles': [],
'dxf_file': active_dxf_file,
'dxf_poles': active_dxf_poles,
'existing_poles_geojson': existing_poles_geojson,
'dxf_files': dxf_files,
'georeferenced_documents_json': georeferenced_documents_json,
@ -4584,7 +4909,7 @@ def integrate_cross_plan(request, intersection_id):
return m
def resolve_lantern_model(name_fr):
m = TrafficLightLanternModel.objects.filter(name_fr__iexact=name_fr).first()
m = find_db_lantern_model(name_fr)
if not m:
m = TrafficLightLanternModel.objects.first()
if not m:
@ -4621,6 +4946,40 @@ def integrate_cross_plan(request, intersection_id):
)
return m
from django.contrib.contenttypes.models import ContentType
from documents.models import DocumentAttachment, ManagedDocument
dxf_files = []
ct = ContentType.objects.get_for_model(intersection)
attachments = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id)
managed_dxf_docs = ManagedDocument.objects.filter(
id__in=attachments.values_list('document_id', flat=True),
is_archived=False
).select_related('current_version')
for doc in managed_dxf_docs:
if doc.current_version and doc.current_version.file:
fn = os.path.basename(doc.current_version.file.name)
if fn.lower().endswith('.dxf') and fn not in dxf_files:
dxf_files.append(fn)
for doc in intersection.documents.all():
if doc.file.name.lower().endswith('.dxf'):
fn = os.path.basename(doc.file.name)
if fn not in dxf_files:
dxf_files.append(fn)
active_dxf_file = request.POST.get('selected_dxf') or (dxf_files[0] if dxf_files else "")
dxf_poles_by_label = {}
if active_dxf_file:
dxf_path = resolve_dxf_file_path(intersection, active_dxf_file)
if dxf_path:
try:
for dp_dxf in parse_dxf_poles(dxf_path):
lbl = (dp_dxf.get('label') or "").strip().upper()
if lbl:
dxf_poles_by_label[lbl] = dp_dxf
except Exception:
pass
def update_pole_geometry(pole, code):
def clean_float(val):
if not val:
@ -4635,9 +4994,22 @@ def integrate_cross_plan(request, intersection_id):
x_3812 = clean_float(request.POST.get(f"x_3812_pole_{code}"))
y_3812 = clean_float(request.POST.get(f"y_3812_pole_{code}"))
if x_3812 is None and lon is None:
short_code = code.split('_')[-1] if '_' in code else code
dxf_match = dxf_poles_by_label.get(short_code.strip().upper())
if dxf_match:
lon = dxf_match.get('lon')
lat = dxf_match.get('lat')
x_3812 = dxf_match.get('x_3812')
y_3812 = dxf_match.get('y_3812')
if x_3812 is not None and y_3812 is not None and x_3812 != 0.0 and y_3812 != 0.0:
from django.contrib.gis.geos import Point as DjangoPoint
from pyproj import Transformer
pole.geom = DjangoPoint(x_3812, y_3812, srid=3812)
if lon is None or lat is None:
transformer = Transformer.from_crs(3812, 4326, always_xy=True)
lon, lat = transformer.transform(x_3812, y_3812)
pole.lon = lon
pole.lat = lat
elif lon is not None and lat is not None:
@ -4762,7 +5134,194 @@ def integrate_cross_plan(request, intersection_id):
messages.success(request, "Tous les anciens assets ont été archivés et les nouveaux ont été créés sur base du plan de croix.")
# B. SELECTIVE OR INDIVIDUAL INTEGRATION
# B. GLOBAL UPDATE ALL (Preserve existing + Update in-place + Create new)
elif global_action == 'update_all':
# --- 1. Poles ---
created_poles = {}
for dp in deduced["poles"]:
code = dp["code"]
short_code = dp.get('short_code', code.split('_')[-1])
existing = TrafficLightPole.objects.filter(intersection=intersection, code=code, status='active').first()
if not existing:
db_id = request.POST.get(f"db_id_pole_{code}")
if db_id:
existing = TrafficLightPole.objects.filter(pk=db_id, status='active').first()
if not existing:
existing = TrafficLightPole.objects.filter(intersection=intersection, code__endswith=f"_{short_code}", status='active').first()
selected_model_id = request.POST.get(f"model_pole_{code}")
db_model = None
if selected_model_id:
db_model = TrafficLightPoleModel.objects.filter(pk=selected_model_id).first()
if not db_model:
db_model = resolve_pole_model(dp["model"])
custom_name = request.POST.get(f"name_pole_{code}", dp.get('name', short_code))
if existing:
existing.code = code
existing.name_fr = custom_name
existing.name_nl = custom_name
if db_model:
existing.model = db_model
existing.updated_by = request.user
update_pole_geometry(existing, code)
existing.save()
created_poles[code] = existing
else:
new_pole = TrafficLightPole(
intersection=intersection,
code=code,
name_fr=custom_name,
name_nl=custom_name,
model=db_model,
status='active',
validation_status='validated',
updated_by=request.user
)
update_pole_geometry(new_pole, code)
new_pole.save()
created_poles[code] = new_pole
# Map active poles for lanterns and detectors parenting
active_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')}
for c, p in created_poles.items():
active_poles[c] = p
# --- 2. Cables ---
for dc in deduced["cables"]:
code = dc["code"]
existing = TrafficLightCable.objects.filter(intersection=intersection, code=code, status='active').first()
if not existing:
db_id = request.POST.get(f"db_id_cable_{code}")
if db_id:
existing = TrafficLightCable.objects.filter(pk=db_id, status='active').first()
selected_model_id = request.POST.get(f"model_cable_{code}")
db_model = None
if selected_model_id:
db_model = TrafficLightCableModel.objects.filter(pk=selected_model_id).first()
if not db_model:
db_model = resolve_cable_model(dc["model_name"])
custom_name = request.POST.get(f"name_cable_{code}", dc["name"])
if existing:
existing.code = code
existing.name_fr = custom_name
existing.name_nl = custom_name
if db_model:
existing.model = db_model
existing.updated_by = request.user
update_cable_geometry(existing, code)
existing.save()
else:
new_cable = TrafficLightCable(
intersection=intersection,
code=code,
name_fr=custom_name,
name_nl=custom_name,
model=db_model,
status='active',
validation_status='validated',
updated_by=request.user
)
update_cable_geometry(new_cable, code)
new_cable.save()
# --- 3. Lanterns ---
for dl in deduced["lanterns"]:
code = dl["code"]
pole = active_poles.get(dl["pole_code"])
db_id = request.POST.get(f"db_id_lantern_{code}")
existing = None
if db_id:
existing = TrafficLightLantern.objects.filter(pk=db_id, status='active').first()
if not existing:
existing = TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code, status='active').first()
selected_model_id = request.POST.get(f"model_lantern_{code}")
db_model = None
if selected_model_id:
db_model = TrafficLightLanternModel.objects.filter(pk=selected_model_id).first()
if not db_model:
db_model = resolve_lantern_model(dl["model_name"])
custom_name = request.POST.get(f"name_lantern_{code}", dl["name"])
if existing:
existing.code = code
if pole:
existing.pole = pole
if db_model:
existing.model = db_model
existing.name_fr = custom_name
existing.name_nl = custom_name
existing.updated_by = request.user
existing.save()
elif pole:
TrafficLightLantern.objects.create(
pole=pole,
code=code,
name_fr=custom_name,
name_nl=custom_name,
model=db_model,
status='active',
validation_status='validated',
updated_by=request.user
)
# --- 4. Detectors ---
for dd in deduced["detectors"]:
code = dd["code"]
pole = active_poles.get(dd["pole_code"])
existing = TrafficLightDetector.objects.filter(intersection=intersection, code=code, status='active').first()
if not existing:
db_id = request.POST.get(f"db_id_detector_{code}")
if db_id:
existing = TrafficLightDetector.objects.filter(pk=db_id, status='active').first()
if not existing:
parts = code.split('_', 2)
if len(parts) == 3:
old_key = f"{parts[0]}-{parts[2]}"
existing = TrafficLightDetector.objects.filter(intersection=intersection, code=old_key, status='active').first()
selected_model_id = request.POST.get(f"model_detector_{code}")
db_model = None
if selected_model_id:
db_model = TrafficLightDetectorModel.objects.filter(pk=selected_model_id).first()
if not db_model:
db_model = resolve_detector_model(dd["model_name"])
custom_name = request.POST.get(f"name_detector_{code}", dd["name"])
if existing:
existing.code = code
if pole:
existing.pole = pole
if db_model:
existing.model = db_model
existing.name = custom_name
existing.name_fr = custom_name
existing.name_nl = custom_name
existing.phases = dd.get("phases", "")
existing.updated_by = request.user
existing.save()
else:
TrafficLightDetector.objects.create(
intersection=intersection,
pole=pole,
code=code,
name=custom_name,
name_fr=custom_name,
name_nl=custom_name,
model=db_model,
phases=dd.get("phases", ""),
status='active',
validation_status='validated',
updated_by=request.user
)
messages.success(request, "Les assets existants ont été mis à jour et les nouveaux ont été créés sans archiver l'existant.")
# C. SELECTIVE OR INDIVIDUAL INTEGRATION
else:
# We iterate over each asset category, checking the user choices from the form post parameters
@ -4848,12 +5407,8 @@ def integrate_cross_plan(request, intersection_id):
except Exception:
pass
else:
# 'keep'
# 'keep' (Ne rien faire)
if existing:
existing.name_fr = custom_name
existing.name_nl = custom_name
update_pole_geometry(existing, code)
existing.save()
created_poles[code] = existing
# Handle poles present in DB only (orphans)
@ -4938,12 +5493,9 @@ def integrate_cross_plan(request, intersection_id):
existing.replaced_by = new_cable
existing.save()
else:
# 'keep'
# 'keep' (Ne rien faire)
if existing:
existing.name_fr = custom_name
existing.name_nl = custom_name
update_cable_geometry(existing, code)
existing.save()
pass
for key in request.POST.keys():
if key.startswith("action_cable_db_"):

View file

@ -16,8 +16,8 @@
cursor: pointer;
}
.dropdown-item-checkbox .form-check-input:checked {
background-color: var(--brand-color);
border-color: var(--brand-color);
background-color: #0d6efd;
border-color: #0d6efd;
}
.dropdown-item-checkbox .form-check-label {
cursor: pointer;
@ -26,7 +26,7 @@
}
.dropdown-item-checkbox .form-check-input:checked + .form-check-label {
font-weight: 600;
color: var(--brand-color) !important;
color: #0f172a !important;
}
th.sortable:hover {
background-color: rgba(47, 62, 139, 0.08) !important;

View file

@ -1,4 +1,5 @@
from django.contrib import admin
from django.contrib.gis import admin as gis_admin
from django.urls import path, reverse
from django.shortcuts import render, redirect
from django.contrib import messages
@ -8,6 +9,8 @@ from django.utils import timezone
from datetime import timedelta
from django.db.models import Case, When, IntegerField, Value
from .models import (
MapBackground,
Territory,
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
UserThematicStatusPermission, UserContractStatusPermission,
@ -23,6 +26,16 @@ from contracts.models import UserCompanyTeamAccess, CompanyMember, CompanyTeam
from .admin_scheduled_tasks import ScheduledCommandAdmin, CommandExecutionLogAdmin
@admin.register(Territory)
class TerritoryAdmin(gis_admin.GISModelAdmin):
list_display = ("name_fr", "name_nl", "code", "is_default", "is_active", "updated_at")
list_filter = ("is_default", "is_active")
search_fields = ("name_fr", "name_nl", "code")
list_editable = ("is_default", "is_active")
ordering = ("-is_default", "name_fr")
@admin.register(Thematic)
class ThematicAdmin(admin.ModelAdmin):
list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract")
@ -1012,3 +1025,31 @@ class UserAssetTypeAccessAdmin(admin.ModelAdmin):
url = f"/assets/permissions/type/{obj.content_type_id}/"
return format_html('<a href="{}" target="_blank">Page du type</a>', url)
type_page_link.short_description = "Page dédiée"
@admin.register(MapBackground)
class MapBackgroundAdmin(admin.ModelAdmin):
list_display = ("name_fr", "name_nl", "region_badge", "category_badge", "service_type", "order", "is_active", "is_default")
list_filter = ("region", "category", "service_type", "is_active", "is_default")
list_editable = ("order", "is_active", "is_default")
search_fields = ("name_fr", "name_nl", "identifier", "attribution", "url")
ordering = ("order", "name_fr")
@admin.display(description="Région")
def region_badge(self, obj):
colors = {
'WAL': '#dc2626',
'BRU': '#2563eb',
'VLG': '#d97706',
'ALL': '#4b5563',
}
bg = colors.get(obj.region, '#4b5563')
return format_html(
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;">{}</span>',
bg,
obj.get_region_display()
)
@admin.display(description="Catégorie")
def category_badge(self, obj):
return obj.get_category_display()

View file

@ -11,6 +11,9 @@ class CommonConfig(AppConfig):
from .middleware import set_login_time
user_logged_in.connect(set_login_time)
# Connecter les signaux de common (ex: invalidation de cache)
import common.signals # noqa
# Charger les extensions conditionnelles
try:
from common import bm_extensions

View file

@ -1,9 +1,12 @@
from __future__ import annotations
import json
import os
from typing import Iterable, List
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
from django.urls import resolve
from .models import UserConfig, AppView
@ -11,6 +14,7 @@ from .models import UserConfig, AppView
SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids"
SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id"
SESSION_MAP_BOUNDS_KEY = "user_map_bounds"
from django.conf import settings
@ -55,6 +59,7 @@ def current_app_view(request):
Only 'sign' and 'documents' have their own dedicated views.
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
@ -67,6 +72,11 @@ def current_app_view(request):
namespace = resolved.namespace # e.g., 'sign', 'home', 'assets'
if namespace:
cache_key = f"appview_for_{namespace}"
cached_view = cache.get(cache_key)
if cached_view is not None:
current_view = None if cached_view is False else cached_view
else:
if namespace in DEDICATED_VIEW_APPS:
# These apps have their own dedicated view
current_view = AppView.objects.filter(url_name__startswith=f"{namespace}:").first()
@ -74,6 +84,8 @@ def current_app_view(request):
# 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:
pass
@ -137,22 +149,264 @@ def linked_accounts(request):
}
def invalidate_user_map_bounds(user_id: int) -> None:
"""
Invalidates the cached map center/zoom/extent for a given user.
Called when UserConfig or UserContractAccess changes.
"""
if user_id:
cache.delete(f"user_map_bounds_{user_id}")
def get_active_map_backgrounds(request=None) -> list:
"""
Returns list of active basemap layers from MapBackground model, cached.
Fallback to defaults if table does not exist or is empty.
Prioritizes basemaps matching user contract municipality region (WAL, BRU, VLG).
"""
cached = cache.get("active_map_backgrounds")
if cached is not None:
return cached
backgrounds = []
try:
from .models import MapBackground
from django.utils.translation import get_language
lang = get_language() or 'fr'
qs = list(MapBackground.objects.filter(is_active=True).order_by('order', 'id'))
# Detect regional priority if user has an assigned municipality
preferred_region = 'ALL'
if request and hasattr(request, 'user') and request.user.is_authenticated:
try:
user_config = getattr(request.user, 'config', None)
if user_config and user_config.default_contract:
muni = user_config.default_contract.municipalities.exclude(geom=None).first()
if muni and muni.niscode:
code = str(muni.niscode).strip()
if code.startswith('21'):
preferred_region = 'BRU'
elif code[:2] in ('25', '51', '52', '53', '55', '56', '57', '61', '62', '63', '81', '82', '83', '84', '85', '91', '92', '93') or code[0] in ('5', '6', '8', '9'):
preferred_region = 'WAL'
elif code[0] in ('1', '3', '4', '7') or code[:2] in ('23', '24'):
preferred_region = 'VLG'
except Exception:
pass
if preferred_region != 'ALL':
# Stable sort: matching region first, then remaining
qs.sort(key=lambda x: (0 if x.region == preferred_region else (1 if x.region == 'ALL' else 2), x.order))
for bg in qs:
backgrounds.append({
'id': bg.identifier,
'name': bg.name_nl if lang == 'nl' and bg.name_nl else bg.name_fr,
'name_fr': bg.name_fr,
'name_nl': bg.name_nl,
'service_type': bg.service_type,
'url': bg.url,
'layers': bg.layers,
'format': bg.format,
'attribution': bg.attribution,
'region': bg.region,
'category': bg.category,
'tileSize': bg.tile_size,
'minZoom': bg.min_zoom,
'maxZoom': bg.max_zoom,
'is_default': bg.is_default,
})
except Exception:
pass
if not backgrounds:
backgrounds = [
{
'id': 'ortho_wallonie',
'name': 'Ortho Wallonie (SPW)',
'name_fr': 'Ortho Wallonie (SPW)',
'name_nl': 'Ortho Wallonië (SPW)',
'service_type': 'wms',
'url': 'https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&version=1.3.0&request=GetMap&layers=0&styles=&format=image/jpeg&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256',
'layers': '0',
'format': 'image/jpeg',
'attribution': '© SPW - Orthophoto Wallonie',
'region': 'WAL',
'category': 'ortho',
'tileSize': 256,
},
{
'id': 'urbis_ortho',
'name': 'Urbis Ortho (Bruxelles)',
'name_fr': 'Urbis Ortho (Bruxelles)',
'name_nl': 'Urbis Ortho (Brussel)',
'service_type': 'wms',
'url': getattr(settings, 'MAP_ORTHO_WMS_URL', None) or 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}',
'layers': 'Ortho',
'format': 'image/png',
'attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', None) or '© Brussels UrbIS',
'region': 'BRU',
'category': 'ortho',
'tileSize': 256,
},
{
'id': 'ortho_vlaanderen',
'name': 'Ortho Vlaanderen',
'name_fr': 'Ortho Vlaanderen',
'name_nl': 'Ortho Vlaanderen',
'service_type': 'wmts',
'url': 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
'layers': 'omwrgb23vl',
'format': 'image/png',
'attribution': '© Digitaal Vlaanderen',
'region': 'VLG',
'category': 'ortho',
'tileSize': 256,
},
{
'id': 'plan_wallonie_spw',
'name': 'Plan de base Wallonie (SPW)',
'name_fr': 'Plan de base Wallonie (SPW)',
'name_nl': 'Basiskaart Wallonië (SPW)',
'service_type': 'arcgis_rest',
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
'layers': '',
'format': 'image/png',
'attribution': '© SPW - Fond de plan Wallonie',
'region': 'WAL',
'category': 'plan',
'tileSize': 256,
},
]
cache.set("active_map_backgrounds", backgrounds, 3600)
return backgrounds
def organization_info(request):
"""
Context processor that exposes organization details from settings.
Dynamically adapts map_default_center, map_default_zoom and map_default_extent
when a default Territory is defined, or when the authenticated user has an
assigned contract associated with a municipality.
Caches calculated bounds in Django cache 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)
# 1. Si l'utilisateur est authentifié et a ses bornes en cache, les utiliser directement (0 requête)
user_cached = False
if hasattr(request, 'user') and request.user.is_authenticated:
cache_key = f"user_map_bounds_{request.user.pk}"
cached_bounds = cache.get(cache_key)
if cached_bounds is not None:
center = cached_bounds.get('center', center)
zoom = cached_bounds.get('zoom', zoom)
extent = cached_bounds.get('extent', extent)
user_cached = True
# 2. Si non présent dans le cache utilisateur et aucun extent dans settings, chercher le Territoire par défaut
if not user_cached and extent is None:
cached_ter_bounds = cache.get("default_territory_bounds")
if cached_ter_bounds is not None:
if cached_ter_bounds:
center = cached_ter_bounds.get('center', center)
zoom = cached_ter_bounds.get('zoom', zoom)
extent = cached_ter_bounds.get('extent', extent)
else:
try:
from .models import Territory
def_ter = Territory.objects.filter(is_active=True, is_default=True, geom__isnull=False).first()
if def_ter:
g = def_ter.geom.transform(4326, clone=True) if def_ter.geom.srid != 4326 else def_ter.geom
min_lon, min_lat, max_lon, max_lat = g.extent
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
cache.set("default_territory_bounds", {'center': center, 'zoom': zoom, 'extent': extent}, 86400)
else:
cache.set("default_territory_bounds", {}, 86400)
except Exception:
pass
# 3. Si utilisateur authentifié sans cache, calculer et mettre en cache ses bornes
if not user_cached and hasattr(request, 'user') and request.user.is_authenticated:
cache_key = f"user_map_bounds_{request.user.pk}"
try:
user_config = getattr(request.user, 'config', None)
if user_config:
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)
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
cache.set(cache_key, {
'center': center,
'zoom': zoom,
'extent': extent,
}, 86400)
except Exception:
pass
return {
'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'),
'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'),
'organization_logo_header': getattr(settings, 'ORGANIZATION_LOGO_HEADER', 'common/android-192.png'),
'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_zoom': getattr(settings, 'MAP_DEFAULT_ZOOM', 11),
'map_default_extent': getattr(settings, 'MAP_DEFAULT_EXTENT', None),
'map_default_center': center,
'map_default_zoom': zoom,
'map_default_extent': extent,
'map_overlay_wms_url': getattr(settings, 'MAP_OVERLAY_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_attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', ''),
'map_backgrounds': get_active_map_backgrounds(request),
'map_backgrounds_json': json.dumps(get_active_map_backgrounds(request)),
}

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

@ -121,9 +121,9 @@
"pk": 11,
"fields": {
"code": "dpv_micro_projects_security",
"name_fr": "DPV Micro-projets Sécurité",
"name_nl": "DPV Micro-projecten Beveiliging",
"description": "",
"name_fr": "Sécurité",
"name_nl": "Veiligheid",
"description": "Gestion de la sécurité publique et des équipements associés",
"icon": "bi-exclamation-diamond",
"color": "#000000",
"uses_occupations": false
@ -142,6 +142,32 @@
"uses_occupations": false
}
},
{
"model": "common.thematic",
"pk": 13,
"fields": {
"code": "parking",
"name_fr": "Stationnement",
"name_nl": "Parkeren",
"description": "Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)",
"icon": "bi-p-square",
"color": "#0d6efd",
"uses_occupations": false
}
},
{
"model": "common.thematic",
"pk": 14,
"fields": {
"code": "clean",
"name_fr": "Propreté",
"name_nl": "Netheid",
"description": "Gestion de la propreté publique, corbeilles et conteneurs",
"icon": "bi-trash3",
"color": "#198754",
"uses_occupations": false
}
},
{
"model": "common.role",
"pk": 1,

View file

@ -133,3 +133,11 @@ msgstr "@"
#: loko/common/static/common/jquery-ui.min.js:6
msgid "!"
msgstr "!"
#: loko/common/static/common/address_search_control.js:148
msgid "Rechercher une adresse…"
msgstr "Search an address…"
msgid "Rechercher une adresse..."
msgstr "Search an address..."

View file

@ -133,3 +133,11 @@ msgstr "@"
#: loko/common/static/common/jquery-ui.min.js:6
msgid "!"
msgstr "!"
#: loko/common/static/common/address_search_control.js:148
msgid "Rechercher une adresse…"
msgstr "Rechercher une adresse…"
msgid "Rechercher une adresse..."
msgstr "Rechercher une adresse..."

View file

@ -135,3 +135,11 @@ msgstr "@"
#: loko/common/static/common/jquery-ui.min.js:6
msgid "!"
msgstr "!"
#: loko/common/static/common/address_search_control.js:148
msgid "Rechercher une adresse…"
msgstr "Zoek een adres…"
msgid "Rechercher une adresse..."
msgstr "Zoek een adres..."

View file

@ -0,0 +1,19 @@
# Generated by Django 6.0.7
from django.db import migrations
def remove_obsolete_schools_appview(apps, schema_editor):
AppView = apps.get_model('common', 'AppView')
AppView.objects.filter(code='schools').delete()
class Migration(migrations.Migration):
dependencies = [
('common', '0014_userconfig_inspection_list_filters_and_more'),
]
operations = [
migrations.RunPython(remove_obsolete_schools_appview, reverse_code=migrations.RunPython.noop),
]

View file

@ -0,0 +1,145 @@
# Generated manually for dynamic MapBackgrounds
from django.db import migrations, models
def seed_map_backgrounds(apps, schema_editor):
MapBackground = apps.get_model('common', 'MapBackground')
initial_data = [
{
'identifier': 'ortho_wallonie',
'name_fr': 'Ortho Wallonie (SPW)',
'name_nl': 'Ortho Wallonië (SPW)',
'service_type': 'wms',
'url': 'https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&version=1.3.0&request=GetMap&layers=0&styles=&format=image/jpeg&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256',
'layers': '0',
'format': 'image/jpeg',
'attribution': '© SPW - Orthophoto Wallonie',
'region': 'WAL',
'category': 'ortho',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 10,
'is_active': True,
'is_default': False,
},
{
'identifier': 'urbis_ortho',
'name_fr': 'Urbis Ortho (Bruxelles)',
'name_nl': 'Urbis Ortho (Brussel)',
'service_type': 'wms',
'url': 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}',
'layers': 'Ortho',
'format': 'image/png',
'attribution': '© Brussels UrbIS',
'region': 'BRU',
'category': 'ortho',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 20,
'is_active': True,
'is_default': False,
},
{
'identifier': 'ortho_vlaanderen',
'name_fr': 'Ortho Vlaanderen',
'name_nl': 'Ortho Vlaanderen',
'service_type': 'wmts',
'url': 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
'layers': 'omwrgb23vl',
'format': 'image/png',
'attribution': '© Digitaal Vlaanderen',
'region': 'VLG',
'category': 'ortho',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 30,
'is_active': True,
'is_default': False,
},
{
'identifier': 'plan_wallonie_spw',
'name_fr': 'Plan de base Wallonie (SPW)',
'name_nl': 'Basiskaart Wallonië (SPW)',
'service_type': 'arcgis_rest',
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
'layers': '',
'format': 'image/png',
'attribution': '© SPW - Fond de plan Wallonie',
'region': 'WAL',
'category': 'plan',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 40,
'is_active': True,
'is_default': False,
},
{
'identifier': 'plan_leger_wallonie_spw',
'name_fr': 'Plan léger Wallonie (SPW)',
'name_nl': 'Lichte basiskaart Wallonië (SPW)',
'service_type': 'arcgis_rest',
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW_LEGER/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
'layers': '',
'format': 'image/png',
'attribution': '© SPW - Fond de plan léger',
'region': 'WAL',
'category': 'plan',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 50,
'is_active': True,
'is_default': False,
},
]
for item in initial_data:
MapBackground.objects.get_or_create(identifier=item['identifier'], defaults=item)
def rollback_seed(apps, schema_editor):
MapBackground = apps.get_model('common', 'MapBackground')
MapBackground.objects.filter(identifier__in=[
'ortho_wallonie', 'urbis_ortho', 'ortho_vlaanderen', 'plan_wallonie_spw', 'plan_leger_wallonie_spw'
]).delete()
class Migration(migrations.Migration):
dependencies = [
('common', '0015_remove_obsolete_schools_appview'),
]
operations = [
migrations.CreateModel(
name='MapBackground',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('identifier', models.SlugField(help_text='Ex: urbis_ortho, ortho_vlaanderen, ortho_wallonie', unique=True, verbose_name='Identifiant unique')),
('name_fr', models.CharField(max_length=150, verbose_name='Nom FR')),
('name_nl', models.CharField(max_length=150, verbose_name='Nom NL')),
('service_type', models.CharField(choices=[('wms', 'WMS (Web Map Service)'), ('wmts', 'WMTS (Web Map Tile Service)'), ('xyz', 'XYZ / Tuiles standard'), ('arcgis_rest', 'ArcGIS REST Export')], default='wms', max_length=20, verbose_name='Type de service')),
('url', models.TextField(help_text='Supporte {bbox-epsg-3857} pour WMS/Export, et {z}/{x}/{y} pour WMTS/XYZ.', verbose_name='URL du flux')),
('layers', models.CharField(blank=True, help_text='Nom de couche WMS si applicable (ex: Ortho, 0, ORTHO_LAST)', max_length=255, verbose_name='Couche(s)')),
('format', models.CharField(default='image/jpeg', help_text='ex: image/jpeg, image/png', max_length=50, verbose_name="Format d'image")),
('attribution', models.CharField(blank=True, help_text='ex: © SPW - Orthophoto Wallonie', max_length=255, verbose_name='Attribution / Copyright')),
('region', models.CharField(choices=[('ALL', 'Toutes régions (National / Global)'), ('BRU', 'Bruxelles (StreetUp)'), ('WAL', 'Wallonie'), ('VLG', 'Flandre')], default='ALL', max_length=10, verbose_name='Région')),
('category', models.CharField(choices=[('ortho', 'Orthophoto / Vue aérienne'), ('plan', 'Plan de base / Topographique'), ('cadastre', 'Cadastre / Parcellaire'), ('other', 'Autre')], default='ortho', max_length=20, verbose_name='Catégorie')),
('tile_size', models.PositiveIntegerField(default=256, verbose_name='Taille des tuiles (px)')),
('min_zoom', models.PositiveIntegerField(default=0, verbose_name='Zoom minimum')),
('max_zoom', models.PositiveIntegerField(default=21, verbose_name='Zoom maximum')),
('order', models.PositiveIntegerField(default=0, verbose_name="Ordre d'affichage")),
('is_active', models.BooleanField(default=True, verbose_name='Actif')),
('is_default', models.BooleanField(default=False, verbose_name='Sélectionné par défaut')),
],
options={
'verbose_name': 'Fond de plan',
'verbose_name_plural': 'Fonds de plan',
'ordering': ['order', 'name_fr'],
},
),
migrations.RunPython(seed_map_backgrounds, rollback_seed),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 6.0.7 on 2026-09-21 08:03
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0016_mapbackground'),
]
operations = [
migrations.CreateModel(
name='Territory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
('name_nl', models.CharField(blank=True, max_length=255, verbose_name='Nom (NL)')),
('code', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='Code')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Géométrie')),
('is_default', models.BooleanField(default=False, help_text='Si coché, ce territoire servira de zone de référence par défaut pour les cartes et masques du territoire.', verbose_name='Territoire par défaut')),
('is_active', models.BooleanField(default=True, verbose_name='Actif')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Créé le')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Modifié le')),
],
options={
'verbose_name': 'Territoire',
'verbose_name_plural': 'Territoires',
'ordering': ['-is_default', 'name_fr'],
},
),
]

View file

@ -85,6 +85,17 @@ class AppView(models.Model):
return self.name_nl
return self.name_fr
@property
def url(self):
"""Renvoie l'URL résolue de la vue ou None si la route n'est pas enregistrée."""
if not self.url_name:
return None
try:
from django.urls import reverse
return reverse(self.url_name)
except Exception:
return None
def __str__(self):
return self.name
@ -1310,5 +1321,131 @@ class RequestLog(models.Model):
return log_entry
class MapBackground(models.Model):
SERVICE_TYPE_CHOICES = [
('wms', _('WMS (Web Map Service)')),
('wmts', _('WMTS (Web Map Tile Service)')),
('xyz', _('XYZ / Tuiles standard')),
('arcgis_rest', _('ArcGIS REST Export')),
]
REGION_CHOICES = [
('ALL', _('Toutes régions (National / Global)')),
('BRU', _('Bruxelles (StreetUp)')),
('WAL', _('Wallonie')),
('VLG', _('Flandre')),
]
CATEGORY_CHOICES = [
('ortho', _('Orthophoto / Vue aérienne')),
('plan', _('Plan de base / Topographique')),
('cadastre', _('Cadastre / Parcellaire')),
('other', _('Autre')),
]
identifier = models.SlugField(max_length=50, unique=True, verbose_name=_("Identifiant unique"), help_text=_("Ex: urbis_ortho, ortho_vlaanderen, ortho_wallonie"))
name_fr = models.CharField(max_length=150, verbose_name=_("Nom FR"))
name_nl = models.CharField(max_length=150, verbose_name=_("Nom NL"))
service_type = models.CharField(max_length=20, choices=SERVICE_TYPE_CHOICES, default='wms', verbose_name=_("Type de service"))
url = models.TextField(verbose_name=_("URL du flux"), help_text=_("Supporte {bbox-epsg-3857} pour WMS/Export, et {z}/{x}/{y} pour WMTS/XYZ."))
layers = models.CharField(max_length=255, blank=True, verbose_name=_("Couche(s)"), help_text=_("Nom de couche WMS si applicable (ex: Ortho, 0, ORTHO_LAST)"))
format = models.CharField(max_length=50, default="image/jpeg", verbose_name=_("Format d'image"), help_text=_("ex: image/jpeg, image/png"))
attribution = models.CharField(max_length=255, blank=True, verbose_name=_("Attribution / Copyright"), help_text=_("ex: © SPW - Orthophoto Wallonie"))
region = models.CharField(max_length=10, choices=REGION_CHOICES, default='ALL', verbose_name=_("Région"))
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='ortho', verbose_name=_("Catégorie"))
tile_size = models.PositiveIntegerField(default=256, verbose_name=_("Taille des tuiles (px)"))
min_zoom = models.PositiveIntegerField(default=0, verbose_name=_("Zoom minimum"))
max_zoom = models.PositiveIntegerField(default=21, verbose_name=_("Zoom maximum"))
order = models.PositiveIntegerField(default=0, verbose_name=_("Ordre d'affichage"))
is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
is_default = models.BooleanField(default=False, verbose_name=_("Sélectionné par défaut"))
@property
def name(self):
lang = get_language()
if lang == 'nl':
return self.name_nl or self.name_fr
return self.name_fr or self.name_nl
objects = LanguageManager()
class Meta:
verbose_name = _("Fond de plan")
verbose_name_plural = _("Fonds de plan")
ordering = ['order', 'name_fr']
def __str__(self):
return f"{self.name} ({self.get_region_display()})"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from django.core.cache import cache
cache.delete("active_map_backgrounds")
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
from django.core.cache import cache
cache.delete("active_map_backgrounds")
class Territory(models.Model):
"""
Définit une zone territoriale administrative ou contractuelle (ex: Région de Bruxelles-Capitale).
Permet d'illuminer directement un polygone global sans jointure complexe de communes,
évitant ainsi les artefacts visuels de lignes intérieures.
"""
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=255, blank=True, verbose_name=_("Nom (NL)"))
code = models.CharField(max_length=50, blank=True, null=True, unique=True, verbose_name=_("Code"))
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie"))
is_default = models.BooleanField(
default=False,
verbose_name=_("Territoire par défaut"),
help_text=_("Si coché, ce territoire servira de zone de référence par défaut pour les cartes et masques du territoire.")
)
is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Modifié le"))
@property
def name(self):
lang = get_language()
if lang == 'nl':
return self.name_nl or self.name_fr
return self.name_fr or self.name_nl
objects = LanguageManager()
class Meta:
verbose_name = _("Territoire")
verbose_name_plural = _("Territoires")
ordering = ['-is_default', 'name_fr']
def __str__(self):
code_str = f" [{self.code}]" if self.code else ""
return f"{self.name}{code_str}"
def save(self, *args, **kwargs):
if self.is_default:
Territory.objects.filter(is_default=True).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
self._clear_cache()
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
self._clear_cache()
def _clear_cache(self):
from django.core.cache import cache
cache.delete(f"territory_mask_geojson_territory_{self.id}")
cache.delete(f"territory_mask_geojson_default_{self.id}")
cache.delete("territory_mask_geojson_default")
cache.delete("default_territory_bounds")
# Import des modèles de tâches planifiées
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog

37
loko/common/signals.py Normal file
View file

@ -0,0 +1,37 @@
import logging
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from .models import UserConfig, UserContractAccess
from .context_processors import invalidate_user_map_bounds
logger = logging.getLogger(__name__)
@receiver(post_save, sender=UserConfig)
@receiver(post_delete, sender=UserConfig)
def invalidate_map_bounds_on_userconfig_change(sender, instance, **kwargs):
"""
Invalidates the cached map center/zoom/extent when UserConfig is modified or deleted
(e.g., when default_contract changes).
"""
if instance.user_id:
invalidate_user_map_bounds(instance.user_id)
@receiver(post_save, sender=UserContractAccess)
@receiver(post_delete, sender=UserContractAccess)
def invalidate_map_bounds_on_contract_access_change(sender, instance, **kwargs):
"""
Invalidates the cached map center/zoom/extent when user contract accesses are added or removed.
"""
try:
user_id = instance.user_config.user_id if instance.user_config else None
if user_id:
invalidate_user_map_bounds(user_id)
except Exception as e:
logger.warning(
"Failed to invalidate map bounds for UserContractAccess %s: %s",
getattr(instance, 'pk', None),
e
)

View file

@ -144,8 +144,9 @@
*/
constructor(opts = {}) {
super();
const defaultPlaceholder = (typeof gettext !== 'undefined' ? gettext('Rechercher une adresse…') : 'Rechercher une adresse…');
this._opts = Object.assign({
placeholder : 'Rechercher une adresse…',
placeholder : defaultPlaceholder,
minChars : 2,
debounce : 250,
markerColor : '#0d6efd',

File diff suppressed because it is too large Load diff

View file

@ -419,3 +419,148 @@ class ScheduledCommandResolveOutputFileTests(TestCase):
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,
)
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_django_cache(self):
request = self.factory.get("/")
self._add_session_to_request(request)
request.user = self.user
cache_key = f"user_map_bounds_{self.user.pk}"
self.assertIsNone(cache.get(cache_key))
ctx1 = organization_info(request)
self.assertIsNotNone(cache.get(cache_key))
# Subsequent call with cache populated performs 0 queries
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"])
def test_user_config_save_invalidates_cache(self):
request = self.factory.get("/")
self._add_session_to_request(request)
request.user = self.user
cache_key = f"user_map_bounds_{self.user.pk}"
organization_info(request)
self.assertIsNotNone(cache.get(cache_key))
# Modifying and saving UserConfig should invalidate the cached map bounds via signal
self.config.save()
self.assertIsNone(cache.get(cache_key))
def test_user_contract_access_invalidates_cache(self):
from datetime import date
from contracts.models import Contract, Company
from common.models import UserContractAccess
company = Company.objects.create(name="Company Test")
contract = Contract.objects.create(
company=company,
contract_number="CTR-TEST-CACHE",
start_date=date(2026, 1, 1),
end_date=date(2026, 12, 31),
)
request = self.factory.get("/")
self._add_session_to_request(request)
request.user = self.user
cache_key = f"user_map_bounds_{self.user.pk}"
organization_info(request)
self.assertIsNotNone(cache.get(cache_key))
# Creating UserContractAccess invalidates cache
access = UserContractAccess.objects.create(user_config=self.config, contract=contract)
self.assertIsNone(cache.get(cache_key))
# Re-populate cache
organization_info(request)
self.assertIsNotNone(cache.get(cache_key))
# Deleting UserContractAccess invalidates cache
access.delete()
self.assertIsNone(cache.get(cache_key))
class TerritoryTests(TestCase):
def setUp(self):
from django.contrib.gis.geos import Polygon, MultiPolygon
from common.models import Territory
# Create a simple square polygon in EPSG:3812
poly = Polygon(((650000, 670000), (660000, 670000), (660000, 680000), (650000, 680000), (650000, 670000)))
self.geom = MultiPolygon(poly, srid=3812)
self.territory = Territory.objects.create(
name_fr="Territoire Test",
name_nl="Test Gebied",
code="TEST",
geom=self.geom,
is_default=True,
is_active=True,
)
def test_single_default_territory_enforced(self):
from common.models import Territory
t2 = Territory.objects.create(
name_fr="Territoire 2",
geom=self.geom,
is_default=True,
is_active=True,
)
self.territory.refresh_from_db()
self.assertFalse(self.territory.is_default)
self.assertTrue(t2.is_default)
def test_get_territory_mask_geojson_uses_default_territory(self):
import json
from django.test import RequestFactory
from django.contrib.auth.models import AnonymousUser
from common.views import get_territory_mask_geojson
factory = RequestFactory()
req = factory.get("/common/territory-mask-geojson/")
req.user = AnonymousUser()
res = get_territory_mask_geojson(req)
self.assertEqual(res.status_code, 200)
data = json.loads(res.content.decode())
self.assertEqual(len(data["features"]), 2)
boundary_feature = next(f for f in data["features"] if f["id"] == "territory_boundary")
self.assertEqual(boundary_feature["properties"]["name"], "Territoire Test")
self.assertEqual(boundary_feature["geometry"]["type"], "MultiPolygon")

View file

@ -6,6 +6,7 @@ app_name = "common"
urlpatterns = [
path("address/autocomplete-street/", views.autocomplete_street, name="autocomplete_street"),
path("address/get-nearest-address/", views.get_nearest_address, name="get_nearest_address"),
path("territory-mask-geojson/", views.get_territory_mask_geojson, name="get_territory_mask_geojson"),
path("get-regional-roads-geojson/", views.get_regional_roads_geojson, name="get_regional_roads_geojson"),
path("get-thematic-categories/", views.get_categories_for_thematics, name="get_thematic_categories"),
path("account-info", views.account_info, name='account_info'),

View file

@ -1,3 +1,4 @@
from django.core.cache import cache
from django.http import JsonResponse, FileResponse, Http404
from django.shortcuts import render, redirect
from django.views.decorators.http import require_GET, require_POST
@ -5,7 +6,7 @@ from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.contrib import messages
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point
from django.contrib.gis.geos import Point, Polygon
from django.contrib.auth import login as auth_login
from django.contrib.auth.decorators import login_not_required
from django.contrib.auth.forms import PasswordChangeForm
@ -22,6 +23,7 @@ from urllib.parse import urlencode
from common.context_processors import (
SESSION_ALLOWED_IDS_KEY,
SESSION_ORIGIN_ID_KEY,
SESSION_MAP_BOUNDS_KEY,
compute_initial_allowed_ids,
)
@ -148,6 +150,168 @@ def get_nearest_address(request):
@login_not_required
@require_GET
def get_territory_mask_geojson(request):
"""
Renvoie le masque cartographique inversé (calque grisé transparent en dehors du territoire)
et le tracé du contour pour mettre en évidence le territoire à gérer.
Supporte un modèle Territory par défaut (contour direct sans jointure de communes),
ou une liste de communes (filtrage par contrat/utilisateur).
"""
import json
user = request.user
muni_params = request.GET.getlist('municipality')
territory_param = request.GET.get('territory_id') or request.GET.get('territory')
def _build_mask_geojson(target_geom, territory_name, cache_key):
cached = cache.get(cache_key)
if cached is not None:
return JsonResponse(cached)
g = target_geom.transform(4326, clone=True) if target_geom.srid != 4326 else target_geom
try:
num_points = sum(len(ring) for poly in g for ring in poly)
except Exception:
num_points = 0
simp_geom = g.simplify(0.00003, preserve_topology=True) if num_points > 800 else g
world = Polygon.from_bbox((-180, -85, 180, 85))
try:
mask = world.difference(simp_geom)
except Exception:
mask = world.difference(simp_geom.buffer(0))
features = [
{
"type": "Feature",
"id": "territory_mask",
"properties": {
"layer": "mask",
"type": "mask",
"name": territory_name
},
"geometry": json.loads(mask.geojson)
},
{
"type": "Feature",
"id": "territory_boundary",
"properties": {
"layer": "boundary",
"type": "boundary",
"name": territory_name
},
"geometry": json.loads(simp_geom.geojson)
}
]
result = {
"type": "FeatureCollection",
"features": features
}
cache.set(cache_key, result, 86400)
return JsonResponse(result)
from common.models import Municipality, Territory
from contracts.models import Contract
from assets.permissions import get_user_accessible_municipalities
# 1. Si un territoire explicite est demandé par identifiant ou code
if territory_param:
t_filter = Q(code=territory_param)
if str(territory_param).isdigit():
t_filter |= Q(id=int(territory_param))
explicit_territory = Territory.objects.filter(t_filter, is_active=True, geom__isnull=False).first()
if explicit_territory:
return _build_mask_geojson(
explicit_territory.geom,
explicit_territory.name,
f"territory_mask_geojson_territory_{explicit_territory.id}"
)
muni_ids = []
if muni_params:
for p in muni_params:
if str(p).isdigit():
muni_ids.append(int(p))
default_territory = Territory.objects.filter(is_active=True, is_default=True, geom__isnull=False).first()
total_munis_count = Municipality.objects.filter(geom__isnull=False).count()
if not muni_ids:
# Vérifier si l'utilisateur est restreint à un sous-ensemble strict de communes
user_config = getattr(user, 'config', None) if user and user.is_authenticated else None
user_restricted_muni_ids = []
if user and user.is_authenticated and not user.is_superuser:
if user_config and user_config.default_contract and user_config.default_contract.municipalities.exists():
c_munis = list(user_config.default_contract.municipalities.values_list('id', flat=True))
if len(c_munis) < total_munis_count:
user_restricted_muni_ids = c_munis
elif user_config and user_config.contracts.filter(municipalities__isnull=False).exists():
c_munis = list(Municipality.objects.filter(contracts__in=user_config.contracts.all()).values_list('id', flat=True).distinct())
if len(c_munis) < total_munis_count:
user_restricted_muni_ids = c_munis
elif Contract.objects.filter(municipalities__isnull=False).count() == 1:
c_munis = list(Contract.objects.filter(municipalities__isnull=False).first().municipalities.values_list('id', flat=True))
if len(c_munis) < total_munis_count:
user_restricted_muni_ids = c_munis
else:
accessible = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
if accessible and len(accessible) < total_munis_count:
user_restricted_muni_ids = accessible
if user_restricted_muni_ids:
muni_ids = user_restricted_muni_ids
elif default_territory:
return _build_mask_geojson(
default_territory.geom,
default_territory.name,
f"territory_mask_geojson_default_{default_territory.id}"
)
else:
muni_ids = list(Municipality.objects.filter(geom__isnull=False).values_list('id', flat=True))
if default_territory and total_munis_count > 0 and len(set(muni_ids)) >= total_munis_count:
return _build_mask_geojson(
default_territory.geom,
default_territory.name,
f"territory_mask_geojson_default_{default_territory.id}"
)
if not muni_ids:
return JsonResponse({"type": "FeatureCollection", "features": []})
muni_ids = sorted(list(set(muni_ids)))
cache_key = f"territory_mask_geojson_{'_'.join(map(str, muni_ids))}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return JsonResponse(cached_data)
munis = Municipality.objects.filter(id__in=muni_ids, geom__isnull=False)
if not munis.exists():
empty_res = {"type": "FeatureCollection", "features": []}
return JsonResponse(empty_res)
combined_geom = None
names = []
for m in munis:
names.append(m.name)
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
g_simp = g.simplify(0.00003, preserve_topology=True)
if combined_geom is None:
combined_geom = g_simp
else:
combined_geom = combined_geom.union(g_simp)
if not combined_geom:
return JsonResponse({"type": "FeatureCollection", "features": []})
return _build_mask_geojson(combined_geom, ", ".join(names), cache_key)
def get_regional_roads_geojson(request):
file_path = os.path.join(settings.BASE_DIR, 'common', 'static', 'geojson', 'regional_roads_simplified_4326.geojson')
@ -287,6 +451,7 @@ def switch_account(request):
session[SESSION_ALLOWED_IDS_KEY] = allowed_ids
session[SESSION_ORIGIN_ID_KEY] = origin_user_id
session.pop(SESSION_MAP_BOUNDS_KEY, None)
return redirect(origin_next or reverse("home:index"))

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', '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', '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'),
@ -154,10 +154,20 @@ class ContractAdmin(admin.ModelAdmin):
),
}),
('Options de workflow', {
'fields': ('needs_approval', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed', 'allow_team_members_edit'),
'fields': ('needs_approval', 'approval_except_urgent', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed', 'allow_team_members_edit', 'auto_assign_control_team'),
}),
)
class Media:
css = {
'all': ('contracts/css/contract_admin.css',)
}
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'
@ -258,18 +268,61 @@ class CompanyTeamMemberInline(admin.TabularInline):
verbose_name_plural = "Membres"
from django.contrib.gis import admin as gis_admin
from django.utils.translation import gettext_lazy as _
class CompanyTeamAdminForm(forms.ModelForm):
operation_types = forms.MultipleChoiceField(
choices=CompanyTeam.OPERATION_TYPES,
widget=forms.CheckboxSelectMultiple,
required=False,
label=_("Types d'opérations gérées"),
)
class Meta:
model = CompanyTeam
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.initial['operation_types'] = self.instance.operation_types or []
def clean_operation_types(self):
return list(self.cleaned_data.get('operation_types') or [])
@admin.register(CompanyTeam)
class CompanyTeamAdmin(admin.ModelAdmin):
list_display = ('name', 'company', 'thematics_list', 'members_count', 'can_generate_daily_report', 'photos_required', 'restrict_today_interventions_visibility', 'visibility_start_hour', 'restrict_to_assigned_interventions')
class CompanyTeamAdmin(gis_admin.GISModelAdmin):
form = CompanyTeamAdminForm
list_display = (
'name', 'company', 'thematics_list', 'operation_types_list', 'has_geom',
'members_count', 'can_generate_daily_report', 'photos_required',
'restrict_today_interventions_visibility', 'visibility_start_hour',
'restrict_to_assigned_interventions'
)
search_fields = ('name', 'company__name')
list_filter = ('company', 'can_generate_daily_report', 'photos_required', 'restrict_today_interventions_visibility', 'restrict_to_assigned_interventions')
filter_horizontal = ('thematics',)
list_filter = (
'company', 'can_generate_daily_report', 'photos_required',
'restrict_today_interventions_visibility', 'restrict_to_assigned_interventions'
)
filter_horizontal = ('thematics', 'asset_categories')
inlines = [CompanyTeamMemberInline]
def thematics_list(self, obj):
return ", ".join([thematic.name for thematic in obj.thematics.all()])
thematics_list.short_description = 'Thématiques'
def operation_types_list(self, obj):
return obj.get_operation_types_display() or "—"
operation_types_list.short_description = _("Opérations gérées")
def has_geom(self, obj):
return bool(obj.geom)
has_geom.boolean = True
has_geom.short_description = _("Zone définie")
def members_count(self, obj):
return obj.members.count()
members_count.short_description = 'Nb membres'

View file

@ -2270,6 +2270,90 @@ msgstr "You do not have permission to generate documents for this order."
msgid "Déclaration de créance créée et sauvegardée."
msgstr "Debt declaration created and saved."
#: contracts/models.py:73
msgid "Inspections"
msgstr "Inspections"
#: contracts/models.py:75
msgid "Contrôles"
msgstr "Controls"
#: contracts/models.py:76
msgid "Observations"
msgstr "Observations"
#: contracts/models.py:93
msgid "Zone géographique"
msgstr "Geographic zone"
#: contracts/models.py:94
msgid "Emprise géographique multi-polygone pour l'attribution automatique."
msgstr "Multi-polygon geographic footprint for automatic assignment."
#: contracts/models.py:99 contracts/admin.py:274
msgid "Types d'opérations gérées"
msgstr "Managed operation types"
#: contracts/models.py:100
msgid "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)."
msgstr "Operation types supported by this team (e.g. Inspections, Interventions, Controls, Observations)."
#: contracts/admin.py:312
msgid "Opérations gérées"
msgstr "Managed operations"
#: contracts/admin.py:318
msgid "Zone définie"
msgstr "Defined zone"
#: contracts/templates/contracts/teams_index.html:679
msgid "1 Mois"
msgstr "1 Month"
#: contracts/templates/contracts/teams_index.html:684
msgid "Portée :"
msgstr "Scope:"
#: contracts/templates/contracts/teams_index.html:685
msgid "Durée appliquée lors de l'assignation ou du retrait d'un membre"
msgstr "Duration applied when assigning or removing a member"
#: contracts/templates/contracts/teams_index.html:686
msgid "Jour"
msgstr "Day"
#: contracts/templates/contracts/teams_index.html:687
msgid "Semaine"
msgstr "Week"
#: contracts/templates/contracts/teams_index.html:688
msgid "Mois"
msgstr "Month"
#: contracts/templates/contracts/teams_index.html:887
msgid "Un mois complet"
msgstr "A full month"
#: contracts/templates/contracts/teams_index.html:924
msgid "Mois source"
msgstr "Source month"
#: contracts/templates/contracts/teams_index.html:928
msgid "Mois cible"
msgstr "Target month"
#: contracts/views_teams.py:648 contracts/views_teams.py:664
msgid "Impossible de modifier une période passée."
msgstr "Cannot modify a past period."
#: contracts/views_teams.py:790
msgid "Le mois cible ne peut pas être dans le passé."
msgstr "Target month cannot be in the past."
#: contracts/views_teams.py:792
msgid "La date cible ne peut pas être dans le passé."
msgstr "Target date cannot be in the past."
#~ msgid "Ce déplacement modifie l'équipe de"
#~ msgstr "This move changes the team of"
@ -2278,3 +2362,12 @@ msgstr "Debt declaration created and saved."
#~ msgid "Confirmer le changement immédiat"
#~ msgstr "Confirm immediate change"
#: contracts/models.py:435
msgid "Assigner automatiquement une équipe de contrôle"
msgstr "Automatically assign a control team"
#: contracts/models.py:436
msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
msgstr "If enabled, a control team is automatically assigned to corrective interventions for this contract."

View file

@ -2320,6 +2320,90 @@ msgstr ""
msgid "Déclaration de créance créée et sauvegardée."
msgstr "Déclaration de créance créée et sauvegardée."
#: contracts/models.py:73
msgid "Inspections"
msgstr "Inspections"
#: contracts/models.py:75
msgid "Contrôles"
msgstr "Contrôles"
#: contracts/models.py:76
msgid "Observations"
msgstr "Observations"
#: contracts/models.py:93
msgid "Zone géographique"
msgstr "Zone géographique"
#: contracts/models.py:94
msgid "Emprise géographique multi-polygone pour l'attribution automatique."
msgstr "Emprise géographique multi-polygone pour l'attribution automatique."
#: contracts/models.py:99 contracts/admin.py:274
msgid "Types d'opérations gérées"
msgstr "Types d'opérations gérées"
#: contracts/models.py:100
msgid "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)."
msgstr "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)."
#: contracts/admin.py:312
msgid "Opérations gérées"
msgstr "Opérations gérées"
#: contracts/admin.py:318
msgid "Zone définie"
msgstr "Zone définie"
#: contracts/templates/contracts/teams_index.html:679
msgid "1 Mois"
msgstr "1 Mois"
#: contracts/templates/contracts/teams_index.html:684
msgid "Portée :"
msgstr "Portée :"
#: contracts/templates/contracts/teams_index.html:685
msgid "Durée appliquée lors de l'assignation ou du retrait d'un membre"
msgstr "Durée appliquée lors de l'assignation ou du retrait d'un membre"
#: contracts/templates/contracts/teams_index.html:686
msgid "Jour"
msgstr "Jour"
#: contracts/templates/contracts/teams_index.html:687
msgid "Semaine"
msgstr "Semaine"
#: contracts/templates/contracts/teams_index.html:688
msgid "Mois"
msgstr "Mois"
#: contracts/templates/contracts/teams_index.html:887
msgid "Un mois complet"
msgstr "Un mois complet"
#: contracts/templates/contracts/teams_index.html:924
msgid "Mois source"
msgstr "Mois source"
#: contracts/templates/contracts/teams_index.html:928
msgid "Mois cible"
msgstr "Mois cible"
#: contracts/views_teams.py:648 contracts/views_teams.py:664
msgid "Impossible de modifier une période passée."
msgstr "Impossible de modifier une période passée."
#: contracts/views_teams.py:790
msgid "Le mois cible ne peut pas être dans le passé."
msgstr "Le mois cible ne peut pas être dans le passé."
#: contracts/views_teams.py:792
msgid "La date cible ne peut pas être dans le passé."
msgstr "La date cible ne peut pas être dans le passé."
#~ msgid "Ce déplacement modifie l'équipe de"
#~ msgstr "Ce déplacement modifie l'équipe de"
@ -2328,3 +2412,12 @@ msgstr "Déclaration de créance créée et sauvegardée."
#~ msgid "Confirmer le changement immédiat"
#~ msgstr "Confirmer le changement immédiat"
#: contracts/models.py:435
msgid "Assigner automatiquement une équipe de contrôle"
msgstr "Assigner automatiquement une équipe de contrôle"
#: contracts/models.py:436
msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
msgstr "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."

View file

@ -2276,6 +2276,90 @@ msgstr ""
msgid "Déclaration de créance créée et sauvegardée."
msgstr "Schuldvordering aangemaakt en opgeslagen."
#: contracts/models.py:73
msgid "Inspections"
msgstr "Inspecties"
#: contracts/models.py:75
msgid "Contrôles"
msgstr "Controles"
#: contracts/models.py:76
msgid "Observations"
msgstr "Observaties"
#: contracts/models.py:93
msgid "Zone géographique"
msgstr "Geografische zone"
#: contracts/models.py:94
msgid "Emprise géographique multi-polygone pour l'attribution automatique."
msgstr "Geografische multi-polygoondekking voor automatische toewijzing."
#: contracts/models.py:99 contracts/admin.py:274
msgid "Types d'opérations gérées"
msgstr "Beheerde bewerkingstypes"
#: contracts/models.py:100
msgid "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)."
msgstr "Bewerkingstypes die door dit team worden ondersteund (bijv. Inspecties, Interventies, Controles, Observaties)."
#: contracts/admin.py:312
msgid "Opérations gérées"
msgstr "Beheerde bewerkingen"
#: contracts/admin.py:318
msgid "Zone définie"
msgstr "Gedefinieerde zone"
#: contracts/templates/contracts/teams_index.html:679
msgid "1 Mois"
msgstr "1 Maand"
#: contracts/templates/contracts/teams_index.html:684
msgid "Portée :"
msgstr "Bereik:"
#: contracts/templates/contracts/teams_index.html:685
msgid "Durée appliquée lors de l'assignation ou du retrait d'un membre"
msgstr "Duur toegepast bij de toewijzing of verwijdering van een lid"
#: contracts/templates/contracts/teams_index.html:686
msgid "Jour"
msgstr "Dag"
#: contracts/templates/contracts/teams_index.html:687
msgid "Semaine"
msgstr "Week"
#: contracts/templates/contracts/teams_index.html:688
msgid "Mois"
msgstr "Maand"
#: contracts/templates/contracts/teams_index.html:887
msgid "Un mois complet"
msgstr "Een volledige maand"
#: contracts/templates/contracts/teams_index.html:924
msgid "Mois source"
msgstr "Bronmaand"
#: contracts/templates/contracts/teams_index.html:928
msgid "Mois cible"
msgstr "Doelmaand"
#: contracts/views_teams.py:648 contracts/views_teams.py:664
msgid "Impossible de modifier une période passée."
msgstr "Kan een verstreken periode niet wijzigen."
#: contracts/views_teams.py:790
msgid "Le mois cible ne peut pas être dans le passé."
msgstr "De doelmaand kan niet in het verleden liggen."
#: contracts/views_teams.py:792
msgid "La date cible ne peut pas être dans le passé."
msgstr "De doeldatum kan niet in het verleden liggen."
#~ msgid "Ce déplacement modifie l'équipe de"
#~ msgstr "Deze verplaatsing wijzigt het team van"
@ -2284,3 +2368,12 @@ msgstr "Schuldvordering aangemaakt en opgeslagen."
#~ msgid "Confirmer le changement immédiat"
#~ msgstr "Onmiddellijke wijziging bevestigen"
#: contracts/models.py:435
msgid "Assigner automatiquement une équipe de contrôle"
msgstr "Automatisch een controleteam toewijzen"
#: contracts/models.py:436
msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
msgstr "Indien geactiveerd wordt automatisch een controleteam toegewezen aan de correctieve interventies van dit contract."

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-09-17 12:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contracts', '0006_usercompanyteamaccess_can_tag'),
]
operations = [
migrations.AddField(
model_name='contract',
name='approval_except_urgent',
field=models.BooleanField(default=True, help_text="Indique si la nécessité d'approbation est levée pour les interventions à priorité urgente.", verbose_name='Sauf si urgence'),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 6.0.7 on 2026-09-19 08:32
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contracts', '0007_contract_approval_except_urgent'),
]
operations = [
migrations.AddField(
model_name='companyteam',
name='geom',
field=django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, help_text="Emprise géographique multi-polygone pour l'attribution automatique.", null=True, srid=3812, verbose_name='Zone géographique'),
),
migrations.AddField(
model_name='companyteam',
name='operation_types',
field=models.JSONField(blank=True, default=list, help_text="Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations).", verbose_name="Types d'opérations gérées"),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-09-19 11:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contracts', '0008_companyteam_geom_companyteam_operation_types'),
]
operations = [
migrations.AddField(
model_name='contract',
name='auto_assign_control_team',
field=models.BooleanField(default=False, help_text='Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat.', verbose_name='Assigner automatiquement une équipe de contrôle'),
),
]

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

@ -6,6 +6,8 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import get_language
from django.utils import timezone
from django.contrib.gis.db import models as gis_models
from common.models import Thematic
import os
@ -54,12 +56,6 @@ class MaintainType(models.Model):
return self.name_nl or self.name_fr
return self.name_fr
def get_name(self):
lang = get_language()
if lang and lang.startswith('nl'):
return self.name_nl or self.name_fr
return self.name_fr
class Meta:
verbose_name = _("Type de maintenance")
verbose_name_plural = _("Types de maintenance")
@ -68,6 +64,18 @@ class MaintainType(models.Model):
class CompanyTeam(models.Model):
"""Modèle représentant une équipe au sein d'une société ou d'un organisme"""
OPERATION_INSPECTIONS = 'inspections'
OPERATION_INTERVENTIONS = 'interventions'
OPERATION_CONTROLS = 'controls'
OPERATION_OBSERVATIONS = 'observations'
OPERATION_TYPES = [
(OPERATION_INSPECTIONS, _('Inspections')),
(OPERATION_INTERVENTIONS, _('Interventions')),
(OPERATION_CONTROLS, _('Contrôles')),
(OPERATION_OBSERVATIONS, _('Observations')),
]
name = models.CharField(max_length=255, unique=True)
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name="teams")
thematics = models.ManyToManyField('common.Thematic', blank=True, related_name="company_teams")
@ -78,6 +86,19 @@ class CompanyTeam(models.Model):
verbose_name=_("Catégories d'assets gérées"),
help_text=_("Catégories d'assets dont cette équipe est responsable."),
)
geom = gis_models.MultiPolygonField(
srid=3812,
null=True,
blank=True,
verbose_name=_("Zone géographique"),
help_text=_("Emprise géographique multi-polygone pour l'attribution automatique.")
)
operation_types = models.JSONField(
default=list,
blank=True,
verbose_name=_("Types d'opérations gérées"),
help_text=_("Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations).")
)
is_assets_manager = models.BooleanField(
default=False,
verbose_name=_("Gestionnaire d'assets"),
@ -115,6 +136,17 @@ class CompanyTeam(models.Model):
help_text=_("Si activé, les membres de cette équipe ne voient que les interventions assignées à l'équipe ou directement à un membre."),
)
def handles_operation(self, op_type: str) -> bool:
"""Indique si l'équipe prend en charge le type d'opération donné."""
if not self.operation_types:
return False
return op_type in self.operation_types
def get_operation_types_display(self) -> str:
"""Retourne la liste lisible des types d'opérations gérées."""
mapping = dict(self.OPERATION_TYPES)
return ", ".join(str(mapping.get(op, op)) for op in (self.operation_types or []))
def __str__(self):
return f"{self.name} ({self.company.name})"
@ -340,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"),
@ -350,6 +394,11 @@ class Contract(models.Model):
verbose_name=_("Nécessite une approbation"),
help_text=_("Indique si les interventions liées à ce contrat nécessitent une approbation avant d'être transmises pour traitement."),
)
approval_except_urgent = models.BooleanField(
default=True,
verbose_name=_("Sauf si urgence"),
help_text=_("Indique si la nécessité d'approbation est levée pour les interventions à priorité urgente."),
)
needs_checking = models.BooleanField(
default=False,
verbose_name=_("Nécessite une vérification"),
@ -395,6 +444,11 @@ class Contract(models.Model):
verbose_name=_("Modification inter-membres d'équipe autorisée"),
help_text=_("Si activé, les membres d'une même équipe peuvent modifier les interventions assignées à d'autres membres de leur équipe, y compris à partir du statut 'Pris en charge' ou au-delà."),
)
auto_assign_control_team = models.BooleanField(
default=False,
verbose_name=_("Assigner automatiquement une équipe de contrôle"),
help_text=_("Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."),
)
is_blocked = models.BooleanField(
default=False,
verbose_name=_("Marché bloqué / gelé"),

View file

@ -2,6 +2,7 @@
Service de gestion temporelle et de planification des équipes.
Gère les appartenances continues, l'historique d'équipe, les absences journalières et la duplication.
"""
import calendar
from collections import defaultdict
from datetime import timedelta, date
from typing import Optional, List, Dict, Set, Any
@ -182,6 +183,51 @@ def set_member_team_schedule(
return new_history
@transaction.atomic
def set_member_team_period_schedule(
member: CompanyMember,
team_id: Optional[int],
start_date: date,
end_date: date,
action: str = 'move',
unassign_team_id: Optional[int] = None,
changed_by=None,
weekdays_only: bool = False,
) -> int:
"""
Affecte ou désaffecte un membre sur une plage de dates continue [start_date, end_date].
Permet de définir des semaines complètes ou des mois entiers en une seule opération.
Ne modifie pas les jours passés (< aujourd'hui).
Retourne le nombre de jours mis à jour.
"""
today = timezone.now().date()
num_days = (end_date - start_date).days + 1
updated_count = 0
for i in range(num_days):
d = start_date + timedelta(days=i)
if d < today:
continue
if weekdays_only and d.weekday() >= 5:
continue
set_member_team_schedule(
member=member,
team_id=team_id,
target_date=d,
changed_by=changed_by,
sync_current=False,
action=action,
unassign_team_id=unassign_team_id,
)
updated_count += 1
if start_date <= today <= end_date:
sync_member_current_teams(member, today)
return updated_count
def _sync_teams_if_needed(member: CompanyMember, target_date: date, today: date, sync_current: bool):
"""Synchronise member.teams si la date modifiée impacte la date d'aujourd'hui."""
if sync_current and target_date <= today:
@ -486,3 +532,40 @@ def duplicate_week_schedule(
total_absences += res['absences_count']
return {'assigned_count': total_assigned, 'absences_count': total_absences}
@transaction.atomic
def duplicate_month_schedule(
source_year: int,
source_month: int,
target_year: int,
target_month: int,
allowed_team_ids: Optional[Set[int]] = None,
copy_absences: bool = False,
changed_by=None,
) -> Dict[str, int]:
"""
Duplique la configuration des équipes d'un mois source vers un mois cible (jour par jour).
Gère automatiquement les longueurs de mois différentes (28 à 31 jours).
"""
_, src_days = calendar.monthrange(source_year, source_month)
_, tgt_days = calendar.monthrange(target_year, target_month)
days_to_copy = min(src_days, tgt_days)
total_assigned = 0
total_absences = 0
for day_num in range(1, days_to_copy + 1):
s_day = date(source_year, source_month, day_num)
t_day = date(target_year, target_month, day_num)
res = duplicate_day_schedule(
source_date=s_day,
target_date=t_day,
allowed_team_ids=allowed_team_ids,
copy_absences=copy_absences,
changed_by=changed_by,
)
total_assigned += res['assigned_count']
total_absences += res['absences_count']
return {'assigned_count': total_assigned, 'absences_count': total_absences}

View file

@ -0,0 +1,16 @@
/* Indentation et mise en valeur ergonomique de l'option "Sauf si urgence" */
.form-row.field-approval_except_urgent {
margin-left: 32px !important;
padding-left: 16px !important;
border-left: 3px solid #79aec8 !important;
background-color: rgba(121, 174, 200, 0.04);
transition: opacity 0.2s ease-in-out;
}
.form-row.field-approval_except_urgent .checkbox-row {
position: relative;
}
.form-row.field-approval_except_urgent label {
font-weight: 500;
}

View file

@ -0,0 +1,19 @@
document.addEventListener('DOMContentLoaded', function() {
const needsApprovalCheckbox = document.getElementById('id_needs_approval');
const exceptUrgentRow = document.querySelector('.form-row.field-approval_except_urgent');
if (needsApprovalCheckbox && exceptUrgentRow) {
function syncExceptUrgentVisibility() {
if (needsApprovalCheckbox.checked) {
exceptUrgentRow.style.opacity = '1';
exceptUrgentRow.style.pointerEvents = 'auto';
} else {
exceptUrgentRow.style.opacity = '0.45';
exceptUrgentRow.style.pointerEvents = 'none';
}
}
needsApprovalCheckbox.addEventListener('change', syncExceptUrgentVisibility);
syncExceptUrgentVisibility();
}
});

View file

@ -138,29 +138,79 @@
}
/* --- Onglet 2 : Planification / Calendrier des équipes --- */
body.tab-planning-active #content-wrapper,
#content-wrapper:has(#tab-planning.active) {
overflow-y: hidden !important;
padding-bottom: 2px !important;
}
.teams-container {
display: flex;
flex-direction: column;
height: 100%;
max-height: calc(100vh - 65px);
overflow: hidden;
}
.teams-header-row {
flex-shrink: 0;
margin-bottom: 6px !important;
}
#teamsTabContent {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.tab-pane.active {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
#tab-current-state.active {
overflow-y: auto;
display: block;
}
#tab-planning.active {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1 1 auto;
min-height: 0;
}
.planning-toolbar {
flex-shrink: 0;
background: #ffffff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 16px;
padding: 8px 12px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.planning-layout {
flex: 1 1 auto;
min-height: 0;
display: flex;
gap: 16px;
align-items: flex-start;
gap: 12px;
align-items: stretch;
overflow: hidden;
}
.planning-matrix-wrapper {
flex: 1 1 auto;
min-width: 0;
height: 100%;
background: #ffffff;
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: auto;
max-height: calc(100vh - 170px);
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
position: relative;
}
@ -168,19 +218,19 @@
.planning-sidebar-wrapper {
width: 300px;
flex: 0 0 300px;
height: 100%;
background: #ffffff;
border: 1px solid #dee2e6;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
position: sticky;
top: 20px;
max-height: calc(100vh - 170px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.planning-sidebar-header {
padding: 12px 14px;
flex-shrink: 0;
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
background: #fdfdfd;
border-top-left-radius: 8px;
@ -188,9 +238,10 @@
}
.planning-sidebar-body {
padding: 10px 14px;
overflow-y: auto;
flex: 1 1 auto;
min-height: 0;
padding: 8px 12px;
overflow-y: auto;
}
/* Table planning */
@ -446,10 +497,10 @@
{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="container-fluid px-3 pt-2 pb-0 teams-container">
<!-- En-tête avec titre & onglets -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2 teams-header-row">
<div>
<h4 class="mb-0">
<i class="bi bi-people-fill me-2 text-primary"></i>{% trans "Gestion des équipes" %}
@ -672,10 +723,24 @@
</div>
<!-- Sélecteur de mode de vue -->
<div class="d-flex align-items-center gap-1">
<span class="text-muted small text-nowrap"><i class="bi bi-eye me-1"></i>{% trans "Vue :" %}</span>
<div class="btn-group btn-group-sm" role="group" id="planning-viewmode-group">
<button type="button" class="btn btn-outline-primary" data-mode="work_week">{% trans "5j (Ouvré)" %}</button>
<button type="button" class="btn btn-primary" data-mode="week">{% trans "1 Semaine (7j)" %}</button>
<button type="button" class="btn btn-outline-primary" data-mode="two_weeks">{% trans "2 Semaines" %}</button>
<button type="button" class="btn btn-outline-primary" data-mode="month">{% trans "1 Mois" %}</button>
</div>
</div>
<!-- Sélecteur global de portée d'assignation -->
<div class="d-flex align-items-center gap-1 ms-1 p-1 px-2 rounded" style="background: #eef2ff; border: 1px solid #c7d2fe;">
<span class="fw-semibold small text-primary-emphasis text-nowrap" title="{% trans "Période sur laquelle un membre est affecté ou retiré lors d'un glisser-déposer" %}"><i class="bi bi-calendar-check me-1 text-primary"></i>{% trans "Affecter par :" %}</span>
<div class="btn-group btn-group-sm" role="group" id="planning-assign-scope-group" title="{% trans "Durée appliquée lors de l'assignation ou du retrait d'un membre" %}">
<button type="button" class="btn btn-primary" data-scope="day">{% trans "Jour" %}</button>
<button type="button" class="btn btn-outline-primary" data-scope="week">{% trans "Semaine" %}</button>
<button type="button" class="btn btn-outline-primary" data-scope="month">{% trans "Mois" %}</button>
</div>
</div>
</div>
@ -830,6 +895,9 @@
{% trans "Vous tentez de l'assigner à l'équipe" %} : <strong id="multiTeamTargetTeamName"></strong>.
{% trans "Souhaitez-vous déplacer ce membre ou l'affecter aux deux équipes ?" %}
</p>
<div class="mt-2 p-2 bg-light rounded small border">
<i class="bi bi-clock-history me-1 text-primary"></i>{% trans "Portée d'affectation :" %} <strong id="multiTeamScopeDisplay">Jour</strong>
</div>
</div>
<div class="modal-footer border-top-0 pt-0 d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">
@ -862,7 +930,7 @@
<!-- Mode de duplication -->
<div class="mb-3">
<label class="form-label fw-semibold">{% trans "Que souhaitez-vous dupliquer ?" %}</label>
<div class="d-flex gap-3">
<div class="d-flex gap-3 flex-wrap">
<div class="form-check">
<input class="form-check-input" type="radio" name="dupMode" id="dupModeDay" value="day" checked>
<label class="form-check-label" for="dupModeDay">{% trans "Une journée" %}</label>
@ -871,6 +939,10 @@
<input class="form-check-input" type="radio" name="dupMode" id="dupModeWeek" value="week">
<label class="form-check-label" for="dupModeWeek">{% trans "Une semaine complète" %}</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="dupMode" id="dupModeMonth" value="month">
<label class="form-check-label" for="dupModeMonth">{% trans "Un mois complet" %}</label>
</div>
</div>
</div>
@ -879,11 +951,11 @@
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Jour source" %}</label>
<input type="text" class="form-control form-control-sm" id="dupSourceDate">
<input type="date" class="form-control form-control-sm" id="dupSourceDate">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Jour cible" %}</label>
<input type="text" class="form-control form-control-sm" id="dupTargetDate">
<input type="date" class="form-control form-control-sm" id="dupTargetDate">
</div>
</div>
</div>
@ -893,11 +965,25 @@
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Semaine source (Lundi)" %}</label>
<input type="text" class="form-control form-control-sm" id="dupSourceWeek">
<input type="date" class="form-control form-control-sm" id="dupSourceWeek">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Semaine cible (Lundi)" %}</label>
<input type="text" class="form-control form-control-sm" id="dupTargetWeek">
<input type="date" class="form-control form-control-sm" id="dupTargetWeek">
</div>
</div>
</div>
<!-- Section Mois -->
<div id="dupSectionMonth" class="d-none">
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Mois source" %}</label>
<input type="month" class="form-control form-control-sm" id="dupSourceMonth">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Mois cible" %}</label>
<input type="month" class="form-control form-control-sm" id="dupTargetMonth">
</div>
</div>
</div>
@ -1565,13 +1651,14 @@ const initialDate = (function() {
const initialViewMode = (function() {
const saved = getStoredSetting('view_mode', 'week');
if (['work_week', 'week', 'two_weeks'].includes(saved)) return saved;
if (['work_week', 'week', 'two_weeks', 'month'].includes(saved)) return saved;
return 'week';
})();
let _planningState = {
anchorDate: initialDate,
viewMode: initialViewMode,
assignScope: getStoredSetting('assign_scope', 'day'),
companyId: getStoredSetting('company_id', ''),
data: null,
members: [],
@ -1584,6 +1671,18 @@ document.addEventListener('DOMContentLoaded', function() {
$('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary');
$(`#planning-viewmode-group button[data-mode="${_planningState.viewMode}"]`).removeClass('btn-outline-primary').addClass('btn-primary');
// Restaurer le sélecteur global de portée d'assignation
$('#planning-assign-scope-group button').removeClass('btn-primary').addClass('btn-outline-primary');
$(`#planning-assign-scope-group button[data-scope="${_planningState.assignScope}"]`).removeClass('btn-outline-primary').addClass('btn-primary');
// Gestion du sélecteur de portée d'assignation
$('#planning-assign-scope-group button').on('click', function() {
$('#planning-assign-scope-group button').removeClass('btn-primary').addClass('btn-outline-primary');
$(this).removeClass('btn-outline-primary').addClass('btn-primary');
_planningState.assignScope = $(this).data('scope');
setStoredSetting('assign_scope', _planningState.assignScope);
});
// Restaurer les filtres de la sidebar
const savedSearch = getStoredSetting('search', '');
if (savedSearch) $('#planning-member-search').val(savedSearch);
@ -1621,7 +1720,7 @@ document.addEventListener('DOMContentLoaded', function() {
loadPlanningData();
});
// Changement de mode de vue (5j / 7j / 14j)
// Changement de mode de vue (5j / 7j / 14j / mois)
$('#planning-viewmode-group button').on('click', function() {
$('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary');
$(this).removeClass('btn-outline-primary').addClass('btn-primary');
@ -1651,17 +1750,24 @@ document.addEventListener('DOMContentLoaded', function() {
filterSidebarMembers();
});
// Mémorisation de l'onglet actif
$('.teams-nav-tabs .nav-link').on('shown.bs.tab', function(e) {
const activeTabId = $(e.target).attr('id');
setStoredSetting('active_tab', activeTabId);
});
// Gestion du basculement d'onglets
function updatePlanningTabClass() {
if ($('#tab-planning-btn').hasClass('active')) {
$('body').addClass('tab-planning-active');
} else {
$('body').removeClass('tab-planning-active');
}
}
// Chargement automatique lors de l'accès à l'onglet Planification
let planningLoadedOnce = false;
$('#tab-planning-btn').on('shown.bs.tab', function() {
$('button[data-bs-toggle="pill"]').on('shown.bs.tab', function(e) {
const tabId = $(e.target).attr('id');
setStoredSetting('active_tab', tabId);
updatePlanningTabClass();
if (tabId === 'tab-planning-btn') {
if (!planningLoadedOnce) {
planningLoadedOnce = true;
}
loadPlanningData();
}
});
@ -1674,16 +1780,14 @@ document.addEventListener('DOMContentLoaded', function() {
bootstrap.Tab.getOrCreateInstance(tabEl).show();
}
}
updatePlanningTabClass();
// Mode duplication modal switch
$('input[name="dupMode"]').on('change', function() {
if ($(this).val() === 'week') {
$('#dupSectionDay').addClass('d-none');
$('#dupSectionWeek').removeClass('d-none');
} else {
$('#dupSectionDay').removeClass('d-none');
$('#dupSectionWeek').addClass('d-none');
}
const val = $(this).val();
$('#dupSectionDay').toggleClass('d-none', val !== 'day');
$('#dupSectionWeek').toggleClass('d-none', val !== 'week');
$('#dupSectionMonth').toggleClass('d-none', val !== 'month');
});
$('#dupSourceDate').on('change', function() {
@ -1721,8 +1825,13 @@ document.addEventListener('DOMContentLoaded', function() {
function navigatePlanningDate(direction) {
const d = new Date(_planningState.anchorDate);
if (_planningState.viewMode === 'month') {
d.setDate(1);
d.setMonth(d.getMonth() + direction);
} else {
const step = _planningState.viewMode === 'two_weeks' ? 14 : 7;
d.setDate(d.getDate() + (direction * step));
}
_planningState.anchorDate = d.toISOString().split('T')[0];
setStoredSetting('anchor_date', _planningState.anchorDate);
_planningState.flatpickrInstance.setDate(_planningState.anchorDate);
@ -1982,6 +2091,7 @@ function executePlanningAssign(memberId, targetTeamId, targetDate, action) {
team_id: parseInt(targetTeamId),
date: targetDate,
action: action || 'move',
scope: _planningState.assignScope || 'day',
},
function(resp) {
loadPlanningData();
@ -2072,6 +2182,11 @@ function initPlanningDragAndDrop() {
targetDate: targetDate
};
let scopeLabel = "{% trans 'Jour'|escapejs %}";
if (_planningState.assignScope === 'week') scopeLabel = "{% trans 'Semaine complète'|escapejs %}";
if (_planningState.assignScope === 'month') scopeLabel = "{% trans 'Mois complet'|escapejs %}";
$('#multiTeamScopeDisplay').text(scopeLabel);
const modalEl = document.getElementById('planningMultiTeamConfirmModal');
const modal = new bootstrap.Modal(modalEl);
modal.show();
@ -2171,6 +2286,7 @@ function unassignMemberFromDate(memberId, dateStr, teamId) {
team_id: null,
date: dateStr,
unassign_team_id: teamId ? parseInt(teamId) : null,
scope: _planningState.assignScope || 'day',
},
function(resp) {
loadPlanningData();
@ -2273,6 +2389,15 @@ function openDuplicateModal() {
const nextWeekDay = String(dWeek.getDate()).padStart(2, '0');
$('#dupTargetWeek').val(`${nextWeekYear}-${nextWeekMonth}-${nextWeekDay}`);
// Initialiser les mois source et cible
const srcMonthVal = `${parts[0]}-${parts[1]}`;
$('#dupSourceMonth').val(srcMonthVal);
const dMonth = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, 1);
dMonth.setMonth(dMonth.getMonth() + 1);
const nextMonthYear = dMonth.getFullYear();
const nextMonthVal = `${nextMonthYear}-${String(dMonth.getMonth() + 1).padStart(2, '0')}`;
$('#dupTargetMonth').val(nextMonthVal);
$('#dupModalAlert').addClass('d-none');
new bootstrap.Modal(document.getElementById('duplicatePlanningModal')).show();
}
@ -2307,8 +2432,17 @@ function quickDuplicateToNextDay(sourceDateStr) {
function submitDuplicatePlanning() {
const mode = $('input[name="dupMode"]:checked').val();
const sourceDate = mode === 'week' ? $('#dupSourceWeek').val() : $('#dupSourceDate').val();
const targetDate = mode === 'week' ? $('#dupTargetWeek').val() : $('#dupTargetDate').val();
let sourceDate, targetDate;
if (mode === 'month') {
sourceDate = $('#dupSourceMonth').val();
targetDate = $('#dupTargetMonth').val();
} else if (mode === 'week') {
sourceDate = $('#dupSourceWeek').val();
targetDate = $('#dupTargetWeek').val();
} else {
sourceDate = $('#dupSourceDate').val();
targetDate = $('#dupTargetDate').val();
}
const copyAbsences = $('#dupCopyAbsences').is(':checked');
if (!sourceDate || !targetDate) {

View file

@ -15,6 +15,8 @@ from contracts.services_teams import (
toggle_member_absence,
duplicate_day_schedule,
duplicate_week_schedule,
duplicate_month_schedule,
set_member_team_period_schedule,
sync_member_current_teams,
)
@ -176,6 +178,41 @@ class TeamPlanningServicesTests(TestCase):
self.assertIn(self.team_b, teams_day2)
def test_set_member_team_period_schedule(self):
"""Tester l'affectation sur une période continue."""
start_d = date(2026, 10, 5) # Lundi
end_d = date(2026, 10, 11) # Dimanche
count = set_member_team_period_schedule(
member=self.member_alice,
team_id=self.team_a.id,
start_date=start_d,
end_date=end_d,
)
self.assertEqual(count, 7)
for day_offset in range(7):
d = start_d + timedelta(days=day_offset)
self.assertEqual(get_member_team_for_date(self.member_alice, d), self.team_a)
def test_duplicate_month_schedule(self):
"""Tester la duplication d'un mois entier vers un autre mois."""
# Configurer Alice dans Team A tous les jours d'octobre 2026 (31 jours)
for day in range(1, 32):
d = date(2026, 10, day)
set_member_team_schedule(self.member_alice, self.team_a.id, d)
# Dupliquer vers novembre 2026 (30 jours)
res = duplicate_month_schedule(
source_year=2026,
source_month=10,
target_year=2026,
target_month=11,
)
self.assertEqual(res['assigned_count'], 30)
# Vérifier que le 15 novembre Alice est bien dans Team A
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 11, 15)), self.team_a)
class TeamPlanningAjaxViewsTests(TestCase):
def setUp(self):
from common.models import UserConfig, Role
@ -255,8 +292,12 @@ class TeamPlanningAjaxViewsTests(TestCase):
def test_planning_duplicate_friday_to_monday_ajax(self):
"""Vérifie la duplication d'un vendredi vers le lundi suivant via l'endpoint AJAX."""
friday = date(2026, 9, 11)
monday = date(2026, 9, 14)
today = timezone.now().date()
days_ahead_to_friday = (4 - today.weekday()) % 7
if days_ahead_to_friday == 0:
days_ahead_to_friday = 7
friday = today + timedelta(days=days_ahead_to_friday)
monday = friday + timedelta(days=3)
set_member_team_schedule(self.member_alice, self.team_b.id, friday)
toggle_member_absence(self.member_alice, friday, is_absent=True, reason='conge', comment='Weekend prolonge')
@ -346,7 +387,8 @@ class TeamPlanningAjaxViewsTests(TestCase):
'target_date': past_date,
}), content_type='application/json')
self.assertEqual(resp_dup.status_code, 400)
self.assertIn('passé', resp_dup.json().get('error', '').lower())
error_msg = resp_dup.json().get('error', '').lower()
self.assertTrue('passé' in error_msg or 'past' in error_msg)
def test_reassign_future_member_to_same_team_as_previous_day(self):
"""
@ -559,5 +601,58 @@ class TeamPlanningAjaxViewsTests(TestCase):
modal_member_ids = [m['id'] for m in resp_modal.json()['current_members']]
self.assertIn(member_olivier.id, modal_member_ids)
def test_planning_data_ajax_month_view(self):
"""Tester le retour du mode 'month' dans team_planning_data_ajax."""
url = reverse('contracts:team_planning_data_ajax')
response = self.client.get(url, {'date': '2026-09-15', 'view_mode': 'month'})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data['days']), 30) # Septembre compte 30 jours
self.assertEqual(data['days'][0]['date'], '2026-09-01')
self.assertEqual(data['days'][-1]['date'], '2026-09-30')
def test_planning_assign_member_ajax_week_scope(self):
"""Tester l'assignation avec portée 'week' via l'API AJAX."""
# Prendre un lundi dans le futur
today = timezone.now().date()
next_monday = today + timedelta(days=(7 - today.weekday()))
url = reverse('contracts:team_planning_assign_member_ajax')
payload = {
'member_id': self.member_alice.id,
'team_id': self.team_a.id,
'date': next_monday.isoformat(),
'scope': 'week',
}
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()['success'])
self.assertEqual(response.json()['scope'], 'week')
self.assertEqual(response.json()['updated_count'], 7)
# Vérifier que le mercredi et le dimanche de cette semaine ont bien Alice
self.assertEqual(get_member_team_for_date(self.member_alice, next_monday + timedelta(days=2)), self.team_a)
self.assertEqual(get_member_team_for_date(self.member_alice, next_monday + timedelta(days=6)), self.team_a)
def test_planning_duplicate_ajax_month_mode(self):
"""Tester la duplication de mois via l'API AJAX."""
# Créer des affectations pour octobre 2026
oct_1 = date(2026, 10, 1)
set_member_team_schedule(self.member_alice, self.team_b.id, oct_1)
url = reverse('contracts:team_planning_duplicate_ajax')
payload = {
'mode': 'month',
'source_date': '2026-10',
'target_date': '2026-11',
'copy_absences': False,
}
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertEqual(data['mode'], 'month')
self.assertGreaterEqual(data['assigned_count'], 1)
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 11, 1)), self.team_b)

View file

@ -1,3 +1,4 @@
import calendar
import json
from datetime import date, timedelta, datetime
@ -21,12 +22,14 @@ from contracts.permissions import (
)
from contracts.services_teams import (
set_member_team_schedule,
set_member_team_period_schedule,
get_member_team_for_date,
get_teams_composition_for_period,
get_member_absences_for_period,
toggle_member_absence,
duplicate_day_schedule,
duplicate_week_schedule,
duplicate_month_schedule,
sync_member_current_teams,
sync_all_members_current_teams,
)
@ -456,6 +459,11 @@ def team_planning_data_ajax(request):
num_days = 14
start_date = monday
end_date = monday + timedelta(days=13)
elif view_mode == 'month':
_, days_in_month = calendar.monthrange(anchor_date.year, anchor_date.month)
num_days = days_in_month
start_date = date(anchor_date.year, anchor_date.month, 1)
end_date = date(anchor_date.year, anchor_date.month, days_in_month)
else: # 'week' (default)
num_days = 7
start_date = monday
@ -602,6 +610,7 @@ def team_planning_assign_member_ajax(request):
member_id = data.get("member_id")
raw_team_id = data.get("team_id")
date_str = data.get("date")
scope = data.get("scope", "day") # 'day', 'week', 'work_week', 'month'
if not member_id or not date_str:
return JsonResponse({"error": "Paramètres member_id et date requis."}, status=400)
@ -611,7 +620,7 @@ def team_planning_assign_member_ajax(request):
return JsonResponse({"error": "Date invalide."}, status=400)
today = timezone.now().date()
if target_date < today:
if scope == "day" and target_date < today:
return JsonResponse({"error": "Impossible de modifier la planification d'un jour passé."}, status=400)
member = get_object_or_404(CompanyMember, id=member_id)
@ -631,7 +640,38 @@ def team_planning_assign_member_ajax(request):
raw_unassign_team_id = data.get("unassign_team_id")
unassign_team_id = int(raw_unassign_team_id) if raw_unassign_team_id is not None else None
# Effectuer la planification
# Effectuer la planification selon la portée choisie
if scope in ("week", "work_week"):
monday = target_date - timedelta(days=target_date.weekday())
week_end = monday + timedelta(days=4 if scope == "work_week" else 6)
if week_end < today:
return JsonResponse({"error": _("Impossible de modifier une période passée.")}, status=400)
updated_count = set_member_team_period_schedule(
member=member,
team_id=team_id,
start_date=monday,
end_date=week_end,
action=action,
unassign_team_id=unassign_team_id,
changed_by=request.user,
weekdays_only=(scope == "work_week"),
)
elif scope == "month":
_, days_in_month = calendar.monthrange(target_date.year, target_date.month)
month_start = date(target_date.year, target_date.month, 1)
month_end = date(target_date.year, target_date.month, days_in_month)
if month_end < today:
return JsonResponse({"error": _("Impossible de modifier une période passée.")}, status=400)
updated_count = set_member_team_period_schedule(
member=member,
team_id=team_id,
start_date=month_start,
end_date=month_end,
action=action,
unassign_team_id=unassign_team_id,
changed_by=request.user,
)
else:
set_member_team_schedule(
member=member,
team_id=team_id,
@ -641,6 +681,7 @@ def team_planning_assign_member_ajax(request):
action=action,
unassign_team_id=unassign_team_id,
)
updated_count = 1
return JsonResponse({
"success": True,
@ -648,6 +689,8 @@ def team_planning_assign_member_ajax(request):
"member_name": member.name,
"team_id": team_id,
"date": target_date.isoformat(),
"scope": scope,
"updated_count": updated_count,
})
@ -728,6 +771,13 @@ def team_planning_duplicate_ajax(request):
if not source_date_str or not target_date_str:
return JsonResponse({"error": "Dates source et cible requises."}, status=400)
# Si mode == 'month' et dates au format 'YYYY-MM', normaliser avec le 1er du mois
if mode == "month":
if len(source_date_str) == 7:
source_date_str = f"{source_date_str}-01"
if len(target_date_str) == 7:
target_date_str = f"{target_date_str}-01"
source_date = parse_date(source_date_str)
target_date = parse_date(target_date_str)
@ -735,8 +785,11 @@ def team_planning_duplicate_ajax(request):
return JsonResponse({"error": "Format de date invalide."}, status=400)
today = timezone.now().date()
if target_date < today:
return JsonResponse({"error": "La date cible ne peut pas être dans le passé."}, status=400)
if mode == "month":
if (target_date.year, target_date.month) < (today.year, today.month):
return JsonResponse({"error": _("Le mois cible ne peut pas être dans le passé.")}, status=400)
elif target_date < today:
return JsonResponse({"error": _("La date cible ne peut pas être dans le passé.")}, status=400)
# Récupérer les équipes que l'utilisateur a le droit d'éditer
viewable = get_viewable_teams_for_user(request.user)
@ -752,7 +805,17 @@ def team_planning_duplicate_ajax(request):
else:
allowed_team_ids = set(viewable.values_list('id', flat=True))
if mode == "week":
if mode == "month":
res = duplicate_month_schedule(
source_year=source_date.year,
source_month=source_date.month,
target_year=target_date.year,
target_month=target_date.month,
allowed_team_ids=allowed_team_ids,
copy_absences=copy_absences,
changed_by=request.user,
)
elif mode == "week":
# S'assurer de pointer sur les lundis
source_monday = source_date - timedelta(days=source_date.weekday())
target_monday = target_date - timedelta(days=target_date.weekday())

View file

@ -435,7 +435,7 @@
<i class="bi {{ entry.status|status_icon }}"></i>
{{ entry.get_status_display }}
</span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }})</span>
</div>
{% if entry.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div>
@ -486,7 +486,7 @@
<i class="bi {{ remark.status|status_icon }}"></i>
{{ remark.get_status_display }}
</span>
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }})</span>
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:"-" }})</span>
</div>
{% if remark.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div>

View file

@ -467,7 +467,7 @@
<!-- ✅ Infos sur l’utilisateur et la date (dernière mise à jour) -->
<p class="small text-muted mb-2">
{% load short_name %}
{% translate 'Mise à jour par' %} <span id="updated-by-{{ remark.id }}">{{ remark.updated_by|short_name|default:remark.updated_by.username }}</span>
{% translate 'Mise à jour par' %} <span id="updated-by-{{ remark.id }}">{{ remark.updated_by|short_name|default:"-" }}</span>
{% translate 'le' %} <span id="updated-at-{{ remark.id }}">{{ remark.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</span>
</p>
@ -483,7 +483,7 @@
<i class="bi {{ entry.status|status_icon }}"></i>
{{ entry.get_status_display }}
</span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }})</span>
</div>
{% if entry.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div>
@ -534,7 +534,7 @@
<i class="bi {{ remark.status|status_icon }}"></i>
{{ remark.get_status_display }}
</span>
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }})</span>
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:"-" }})</span>
</div>
{% if remark.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div>

View file

@ -36,7 +36,7 @@
<i class="bi {{ entry.status|status_icon }}"></i>
{{ entry.get_status_display }}
</span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }})</span>
</div>
{% if entry.description %}
<div class="small text-dark" style="white-space: pre-wrap;">{{ entry.description }}</div>

View file

@ -731,9 +731,21 @@ class DocumentFolderAttachment(models.Model):
def duplicate_file(field):
if not field:
return None
try:
file_name = os.path.basename(field.name)
with field.open("rb") as f:
return ContentFile(f.read(), name=file_name)
except Exception:
# Fallback si le fichier historique est présent dans MEDIA_ROOT avant migration vers private storage
try:
if hasattr(settings, "MEDIA_ROOT") and field.name:
alt_path = os.path.join(settings.MEDIA_ROOT, field.name)
if os.path.exists(alt_path):
with open(alt_path, "rb") as f:
return ContentFile(f.read(), name=os.path.basename(field.name))
except Exception:
pass
return None
def ensure_document_for_legacy(instance, old_file_name=None):
@ -757,6 +769,16 @@ def ensure_document_for_legacy(instance, old_file_name=None):
context_object = instance.get_document_context()
title = instance.get_document_title()
file_changed = bool(old_file_name and old_file_name != instance.file.name)
should_create_version = (managed is None) or not managed.versions.exists() or file_changed
if should_create_version:
new_file = duplicate_file(instance.file)
if not new_file:
# Ne pas créer de ManagedDocument orphelin si le fichier physique est inaccessible
return
with transaction.atomic():
if managed is None:
managed = ManagedDocument.objects.create(
title=title,
@ -784,11 +806,6 @@ def ensure_document_for_legacy(instance, old_file_name=None):
)
managed.types.add(doc_type)
file_changed = bool(old_file_name and old_file_name != instance.file.name)
should_create_version = not managed.versions.exists() or file_changed
if should_create_version:
new_file = duplicate_file(instance.file)
thumbnail_file = duplicate_file(getattr(instance, "thumbnail", None))
version = DocumentVersion(
@ -802,6 +819,7 @@ def ensure_document_for_legacy(instance, old_file_name=None):
validated_at=timezone.now(),
)
version.save()
managed.touch(version)
elif managed.current_version is None:
managed.touch(managed.versions.order_by("-version_number").first())

View file

@ -331,6 +331,7 @@ class DocumentManagementService:
"message": _("New version added to existing document."),
}
else:
with transaction.atomic():
# Déterminer la visibilité
if visibility is None:
if content_object and getattr(getattr(content_object, '_meta', None), 'model_name', None) == 'project':

View file

@ -20,7 +20,7 @@
</span>
{% endif %}
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}"
class="nav-link flex-grow-1 d-flex align-items-center p-2 {% if active_slug == folder.slug %}active{% endif %} {% if folder.slug in trail_slugs and active_slug != folder.slug %} fw-semibold{% endif %}" style="color: var(--brand-color);">
class="nav-link flex-grow-1 d-flex align-items-center p-2 {% if active_slug == folder.slug %}active{% endif %} {% if folder.slug in trail_slugs and active_slug != folder.slug %} fw-semibold{% endif %}" >
<i class="bi bi-folder{% if active_slug == folder.slug %}-fill{% endif %}"></i>
<span class="ms-2 text d-none d-md-inline">{{ folder.name }}</span>
</a>

View file

@ -656,10 +656,11 @@ msgstr ""
msgid "En pause"
msgstr ""
#: home/templates/home/widgets/controller_shortcuts.html:36
#: home/templates/home/widgets/external_interventions.html:84
#: home/templates/home/widgets/external_interventions.html:85
msgid "À corriger"
msgstr ""
msgstr "To be corrected"
#: home/templates/home/widgets/external_interventions.html:94
msgid "10 dernières interventions"
@ -1111,3 +1112,28 @@ msgstr "Loaned items"
#: home/templates/home/widgets/stock_summary.html:235
msgid "Aucun article en prêt en cours"
msgstr "No active loaned items"
#: home/models.py:38
#: home/templates/home/widgets/controller_shortcuts.html:11
msgid "Raccourcis contrôleur"
msgstr "Controller shortcuts"
#: home/templates/home/widgets/controller_shortcuts.html:15
msgid "Interventions à contrôler"
msgstr "Interventions to control"
#: home/templates/home/widgets/controller_shortcuts.html:18
msgid "en attente de contrôle"
msgstr "pending control"
#: home/templates/home/widgets/controller_shortcuts.html:43
msgid "Validées"
msgstr "Validated"
#: home/templates/home/widgets/controller_shortcuts.html:64
msgid "Aucune intervention à contrôler"
msgstr "No interventions to control"
#: home/templates/home/widgets/controller_shortcuts.html:72
msgid "Voir toutes les interventions à contrôler"
msgstr "View all interventions to control"

View file

@ -665,10 +665,11 @@ msgstr ""
msgid "En pause"
msgstr ""
#: home/templates/home/widgets/controller_shortcuts.html:36
#: home/templates/home/widgets/external_interventions.html:84
#: home/templates/home/widgets/external_interventions.html:85
msgid "À corriger"
msgstr ""
msgstr "À corriger"
#: home/templates/home/widgets/external_interventions.html:94
msgid "10 dernières interventions"
@ -1120,3 +1121,28 @@ msgstr "Articles en prêt"
#: home/templates/home/widgets/stock_summary.html:235
msgid "Aucun article en prêt en cours"
msgstr "Aucun article en prêt en cours"
#: home/models.py:38
#: home/templates/home/widgets/controller_shortcuts.html:11
msgid "Raccourcis contrôleur"
msgstr "Raccourcis contrôleur"
#: home/templates/home/widgets/controller_shortcuts.html:15
msgid "Interventions à contrôler"
msgstr "Interventions à contrôler"
#: home/templates/home/widgets/controller_shortcuts.html:18
msgid "en attente de contrôle"
msgstr "en attente de contrôle"
#: home/templates/home/widgets/controller_shortcuts.html:43
msgid "Validées"
msgstr "Validées"
#: home/templates/home/widgets/controller_shortcuts.html:64
msgid "Aucune intervention à contrôler"
msgstr "Aucune intervention à contrôler"
#: home/templates/home/widgets/controller_shortcuts.html:72
msgid "Voir toutes les interventions à contrôler"
msgstr "Voir toutes les interventions à contrôler"

Some files were not shown because too many files have changed in this diff Show more