diff --git a/Dockerfile b/Dockerfile index 56e63fd..92e86c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/loko/api/urls.py b/loko/api/urls.py index 36f9a80..a66d479 100644 --- a/loko/api/urls.py +++ b/loko/api/urls.py @@ -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"), diff --git a/loko/api/views_version.py b/loko/api/views_version.py new file mode 100644 index 0000000..8e0039c --- /dev/null +++ b/loko/api/views_version.py @@ -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()) diff --git a/loko/assets/locale/en/LC_MESSAGES/djangojs.mo b/loko/assets/locale/en/LC_MESSAGES/djangojs.mo index 4020d54..8d561b1 100644 Binary files a/loko/assets/locale/en/LC_MESSAGES/djangojs.mo and b/loko/assets/locale/en/LC_MESSAGES/djangojs.mo differ diff --git a/loko/assets/locale/en/LC_MESSAGES/djangojs.po b/loko/assets/locale/en/LC_MESSAGES/djangojs.po index 2805ae3..727b478 100644 --- a/loko/assets/locale/en/LC_MESSAGES/djangojs.po +++ b/loko/assets/locale/en/LC_MESSAGES/djangojs.po @@ -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." + diff --git a/loko/assets/locale/fr/LC_MESSAGES/djangojs.mo b/loko/assets/locale/fr/LC_MESSAGES/djangojs.mo index 390f950..03cf3c4 100644 Binary files a/loko/assets/locale/fr/LC_MESSAGES/djangojs.mo and b/loko/assets/locale/fr/LC_MESSAGES/djangojs.mo differ diff --git a/loko/assets/locale/fr/LC_MESSAGES/djangojs.po b/loko/assets/locale/fr/LC_MESSAGES/djangojs.po index a939788..807bb46 100644 --- a/loko/assets/locale/fr/LC_MESSAGES/djangojs.po +++ b/loko/assets/locale/fr/LC_MESSAGES/djangojs.po @@ -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." + diff --git a/loko/assets/locale/nl/LC_MESSAGES/djangojs.mo b/loko/assets/locale/nl/LC_MESSAGES/djangojs.mo index 89286cb..dc3205d 100644 Binary files a/loko/assets/locale/nl/LC_MESSAGES/djangojs.mo and b/loko/assets/locale/nl/LC_MESSAGES/djangojs.mo differ diff --git a/loko/assets/locale/nl/LC_MESSAGES/djangojs.po b/loko/assets/locale/nl/LC_MESSAGES/djangojs.po index 34a8e14..ec79502 100644 --- a/loko/assets/locale/nl/LC_MESSAGES/djangojs.po +++ b/loko/assets/locale/nl/LC_MESSAGES/djangojs.po @@ -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." + diff --git a/loko/assets/management/commands/create_structure_folders.py b/loko/assets/management/commands/create_structure_folders.py new file mode 100644 index 0000000..2af7ef9 --- /dev/null +++ b/loko/assets/management/commands/create_structure_folders.py @@ -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 : " - " (ex: "MS001 - Station Albert") + - Slug : "" (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}" + )) diff --git a/loko/assets/management/commands/import_cse_parking.py b/loko/assets/management/commands/import_cse_parking.py new file mode 100644 index 0000000..479896c --- /dev/null +++ b/loko/assets/management/commands/import_cse_parking.py @@ -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()}).")) diff --git a/loko/assets/management/commands/import_cse_signage.py b/loko/assets/management/commands/import_cse_signage.py new file mode 100644 index 0000000..d159819 --- /dev/null +++ b/loko/assets/management/commands/import_cse_signage.py @@ -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()}")) diff --git a/loko/assets/management/commands/load_base_asset_categories.py b/loko/assets/management/commands/load_base_asset_categories.py new file mode 100644 index 0000000..fbe0fa7 --- /dev/null +++ b/loko/assets/management/commands/load_base_asset_categories.py @@ -0,0 +1,125 @@ +""" +Management command to load baseline asset categories (from loko-bm) and +associate existing asset models and assets with their corresponding categories. +""" +import json +import os +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand +from common.models import Thematic +from assets.models import ( + AssetCategory, + CleanLitterBin, CleanLitterBinModel, + CleanGlassContainer, CleanGlassContainerModel, + RoadDrain, RoadDrainModel, + Roadway, RoadwayModel, + RoadSidewalk, RoadSidewalkModel, + RoadFurniture, RoadFurnitureModel, + StructureAssetModel, +) + + +class Command(BaseCommand): + help = "Charge les catégories d'assets de base (définies dans loko-bm) et lie les modèles existants." + + def add_arguments(self, parser): + parser.add_argument( + '--file', + type=str, + help="Chemin vers le fichier JSON des catégories (défaut: common/fixtures/asset_categories.json)", + ) + + def handle(self, *args, **options): + # 1. Localiser le fichier JSON + json_path = options.get('file') + candidates = [] + if not json_path: + base_dir = getattr(settings, 'BASE_DIR', None) + candidates = [ + os.path.join(base_dir, 'common', 'fixtures', 'asset_categories.json') if base_dir else None, + os.path.join(base_dir, 'loko', 'common', 'fixtures', 'asset_categories.json') if base_dir else None, + '/app/common/fixtures/asset_categories.json', + '/app/loko/common/fixtures/asset_categories.json', + os.path.join(os.path.dirname(__file__), '..', '..', '..', 'common', 'fixtures', 'asset_categories.json'), + ] + for c in candidates: + if c and os.path.exists(c): + json_path = os.path.abspath(c) + break + + if not json_path or not os.path.exists(json_path): + self.stderr.write(self.style.ERROR(f"Fichier asset_categories.json introuvable. Candidats testés: {candidates}")) + return + + self.stdout.write(f"Chargement des catégories depuis : {json_path}") + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + created_count = 0 + updated_count = 0 + + # Cache thématiques + thematics_by_code = {t.code: t for t in Thematic.objects.all()} + + # 2. Création / Mise à jour des catégories + for item in data: + code = item['code'] + name_fr = item['name_fr'] + name_nl = item.get('name_nl') or name_fr + thematic_code = item.get('thematic_code') + thematic = thematics_by_code.get(thematic_code) + + cat, created = AssetCategory.objects.update_or_create( + code=code, + defaults={ + 'name_fr': name_fr, + 'name_nl': name_nl, + 'thematic': thematic, + } + ) + if created: + created_count += 1 + else: + updated_count += 1 + + # Modèles Django autorisés (ContentType) + allowed_models = item.get('allowed_models', []) + if allowed_models: + cts = ContentType.objects.filter(model__in=allowed_models) + cat.allowed_models.set(cts) + + self.stdout.write(self.style.SUCCESS( + f"✅ Catégories d'assets : {created_count} créées, {updated_count} mises à jour (Total: {len(data)})." + )) + + # 3. Association automatique des modèles d'assets existants aux catégories correspondantes + self.link_models_to_categories() + + def link_models_to_categories(self): + links = [ + ('CleanLitterBinModel', CleanLitterBinModel, 'CL_BASKET'), + ('CleanLitterBin', CleanLitterBin, 'CL_BASKET'), + ('CleanGlassContainerModel', CleanGlassContainerModel, 'CL_BUBBLE'), + ('CleanGlassContainer', CleanGlassContainer, 'CL_BUBBLE'), + ('RoadDrainModel', RoadDrainModel, 'RD_DRAIN'), + ('RoadDrain', RoadDrain, 'RD_DRAIN'), + ('RoadwayModel', RoadwayModel, 'RD_ROADWAY'), + ('Roadway', Roadway, 'RD_ROADWAY'), + ('RoadSidewalkModel', RoadSidewalkModel, 'RD_SIDEWALK'), + ('RoadSidewalk', RoadSidewalk, 'RD_SIDEWALK'), + ('RoadFurnitureModel', RoadFurnitureModel, 'RD_STREET_FURNITURE'), + ('RoadFurniture', RoadFurniture, 'RD_STREET_FURNITURE'), + ('StructureAssetModel', StructureAssetModel, 'TU_CIVIL_ENGINEERING'), + ] + + for name, model_cls, cat_code in links: + cat = AssetCategory.objects.filter(code=cat_code).first() + if not cat: + continue + try: + updated = model_cls.objects.filter(category__isnull=True).update(category=cat) + if updated: + self.stdout.write(f" • {name} : {updated} enregistrement(s) lié(s) à la catégorie {cat.name_fr} ({cat_code}).") + except Exception as e: + self.stderr.write(f" ⚠️ Erreur liaison {name} -> {cat_code}: {e}") diff --git a/loko/assets/migrations/0010_add_parking_thematic.py b/loko/assets/migrations/0010_add_parking_thematic.py index 6562ad1..6571f77 100644 --- a/loko/assets/migrations/0010_add_parking_thematic.py +++ b/loko/assets/migrations/0010_add_parking_thematic.py @@ -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', diff --git a/loko/assets/migrations/0017_add_bike_parking_category.py b/loko/assets/migrations/0017_add_bike_parking_category.py index 70af41f..09a130d 100644 --- a/loko/assets/migrations/0017_add_bike_parking_category.py +++ b/loko/assets/migrations/0017_add_bike_parking_category.py @@ -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', diff --git a/loko/assets/migrations/0027_alter_structure_structure_type.py b/loko/assets/migrations/0027_alter_structure_structure_type.py new file mode 100644 index 0000000..fc1ee09 --- /dev/null +++ b/loko/assets/migrations/0027_alter_structure_structure_type.py @@ -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), + ), + ] diff --git a/loko/assets/models/structures.py b/loko/assets/models/structures.py index edbc81e..cfc62f6 100644 --- a/loko/assets/models/structures.py +++ b/loko/assets/models/structures.py @@ -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')), ) diff --git a/loko/assets/permissions.py b/loko/assets/permissions.py index e5074f2..ac644bc 100644 --- a/loko/assets/permissions.py +++ b/loko/assets/permissions.py @@ -796,3 +796,92 @@ def can_edit_exceptional_transport(user): and getattr(user_config, 'exceptional_transport_can_edit', False) ) + + +def get_user_accessible_municipalities(user): + """ + Retourne le queryset des communes (Municipality) auxquelles l'utilisateur a accès. + - Si superuser ou (utilisateur interne sans limitation aux contrats) : + retourne toutes les communes (Municipality.objects.all()). + - Si utilisateur externe ou avec limit_assets_to_contracts=True : + retourne uniquement les communes associées à ses contrats accessibles + (via Contract.municipalities ou localisations liées). + """ + from common.models import Municipality, UserConfig, UserContractAccess + from django.utils.translation import get_language + + if not user or not user.is_authenticated: + return Municipality.objects.none() + + if getattr(user, 'is_superuser', False): + lang = get_language() or 'fr' + order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' + return Municipality.objects.all().order_by(order_field) + + user_config = getattr(user, 'config', None) + if not user_config: + try: + user_config = UserConfig.objects.get(user=user) + except UserConfig.DoesNotExist: + return Municipality.objects.none() + + if user_config.has_role('admin') or user_config.has_role('top_manager'): + lang = get_language() or 'fr' + order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' + return Municipality.objects.all().order_by(order_field) + + is_external = not user_config.is_intern + must_limit = is_external or user_config.limit_assets_to_contracts + + if not must_limit: + lang = get_language() or 'fr' + order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' + return Municipality.objects.all().order_by(order_field) + + # Utilisateur restreint par contrats : + accessible_contract_ids = set( + UserContractAccess.objects.filter( + user_config=user_config, + can_view_assets=True + ).values_list('contract_id', flat=True) + ) + if user_config.default_contract_id: + accessible_contract_ids.add(user_config.default_contract_id) + + if not accessible_contract_ids: + return Municipality.objects.none() + + # Communes associées via ManyToMany Contract.municipalities + municipality_ids = set( + Municipality.objects.filter( + contracts__id__in=accessible_contract_ids + ).values_list('id', flat=True) + ) + + # Communes associées via les localisations liées aux contrats + try: + from assets.models import CleanLocationContract + clean_muni_ids = CleanLocationContract.objects.filter( + contract_id__in=accessible_contract_ids, + location__municipality__isnull=False + ).values_list('location__municipality_id', flat=True) + municipality_ids.update(clean_muni_ids) + except Exception: + pass + + try: + from assets.models import TrafficLightContract + tl_muni_ids = TrafficLightContract.objects.filter( + contract_id__in=accessible_contract_ids, + intersection__municipality__isnull=False + ).values_list('intersection__municipality_id', flat=True) + municipality_ids.update(tl_muni_ids) + except Exception: + pass + + if not municipality_ids: + return Municipality.objects.none() + + lang = get_language() or 'fr' + order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' + return Municipality.objects.filter(id__in=municipality_ids).order_by(order_field) diff --git a/loko/assets/static/assets/asset_symbology.js b/loko/assets/static/assets/asset_symbology.js index b82e974..062d392 100644 --- a/loko/assets/static/assets/asset_symbology.js +++ b/loko/assets/static/assets/asset_symbology.js @@ -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, }; diff --git a/loko/assets/static/assets/assets_index.css b/loko/assets/static/assets/assets_index.css index 8c8ffc7..773d47d 100644 --- a/loko/assets/static/assets/assets_index.css +++ b/loko/assets/static/assets/assets_index.css @@ -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 */ } \ No newline at end of file diff --git a/loko/assets/static/assets/clean_glass_container_icon.svg b/loko/assets/static/assets/clean_glass_container_icon.svg new file mode 100644 index 0000000..fb4b9ab --- /dev/null +++ b/loko/assets/static/assets/clean_glass_container_icon.svg @@ -0,0 +1,48 @@ + diff --git a/loko/assets/static/assets/inspection_map.js b/loko/assets/static/assets/inspection_map.js index f0001ac..4dbcdc6 100644 --- a/loko/assets/static/assets/inspection_map.js +++ b/loko/assets/static/assets/inspection_map.js @@ -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 = `
- #${order} (${labelFr || 'Panneau'}) + #${order} (${labelFr || (window.gettext ? window.gettext('Panneau') : 'Panneau')}) ${confidence ? `IA: ${Math.round(confidence * 100)}%` : ''} -
@@ -1994,23 +2001,23 @@
- Code + ${window.gettext ? window.gettext('Code') : 'Code'}
- +
- Propreté : + ${window.gettext ? window.gettext('Propreté :') : 'Propreté :'}
- + - + - + - +
`; @@ -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 = ' Création en cours...'; + const creatingMsg = window.gettext ? window.gettext('Création en cours...') : 'Création en cours...'; + btnSubmit.innerHTML = ` ${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 = ' Créer et Enregistrer'; + const createAndSaveMsg = window.gettext ? window.gettext('Créer et Enregistrer') : 'Créer et Enregistrer'; + btnSubmit.innerHTML = ` ${createAndSaveMsg}`; } }); } @@ -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(() => { diff --git a/loko/assets/static/assets/nature_planter_icon.svg b/loko/assets/static/assets/nature_planter_icon.svg new file mode 100644 index 0000000..75dd986 --- /dev/null +++ b/loko/assets/static/assets/nature_planter_icon.svg @@ -0,0 +1,60 @@ + diff --git a/loko/assets/static/assets/road_speedbump_icon.svg b/loko/assets/static/assets/road_speedbump_icon.svg new file mode 100644 index 0000000..111fc6f --- /dev/null +++ b/loko/assets/static/assets/road_speedbump_icon.svg @@ -0,0 +1,38 @@ + diff --git a/loko/assets/static/assets/trafficlights_intersection_detail.js b/loko/assets/static/assets/trafficlights_intersection_detail.js index 11f639d..ebf0d0f 100644 --- a/loko/assets/static/assets/trafficlights_intersection_detail.js +++ b/loko/assets/static/assets/trafficlights_intersection_detail.js @@ -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 = ' 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,11 +924,13 @@ function showGeorefImage(doc, active, opacity) { } // Zoom/fitBounds to document - 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 }); + 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 = ` -
- - ${doc.title} - -
- -
-
- - `; - 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,101 +1010,389 @@ 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 = `
Erreur de chargement.
`; + spinner.innerHTML = `
Erreur de chargement.
`; } }); } -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 = ` -
- -
@@ -253,6 +265,7 @@ {% translate "Code Asset" %} {% translate "Nom" %} {% translate "Statut" %} + {% translate "Position / Géométrie" %} {% translate "Modèle Déduit (Excel)" %} {% translate "Modèle Actuel (DB)" %} {% translate "Décision" %} @@ -269,7 +282,7 @@ {% if item.status == 'new' %} - {% translate "Nouveau" %} + {% translate "Nouveau" %} {% elif item.status == 'modified' %} {% translate "Modifié" %} {% elif item.status == 'identical' %} @@ -283,6 +296,45 @@ {% endfor %} + + {% if item.geom_modified %} + + + {% if item.distance_diff is not None %} + {% translate "Décalage" %} {{ item.distance_diff }} m + {% else %} + {% translate "Position modifiée" %} + {% endif %} + + + {% elif item.new_lon is not None %} + {% if item.status == 'new' %} + + {% if item.new_x_3812 %}DXF{% else %}{% translate "Plan" %}{% endif %} + + {% elif item.status == 'db_only' %} + + DB + + {% else %} + + {% translate "Inchangée" %} + + {% endif %} + + {% else %} + + {% translate "Non positionné" %} + + + {% endif %} + {{ item.deduced_model_name|default:"—" }} {{ item.db_model_name|default:"—" }} @@ -302,6 +354,7 @@ {% elif item.status == 'identical' %} + {% endif %} @@ -328,7 +381,7 @@ {% empty %} - {% translate "Aucun poteau détecté." %} + {% translate "Aucun poteau détecté." %} {% endfor %} @@ -450,11 +503,11 @@ {% for item in detectors %} - + {{ item.code }} - + {{ item.pole_code|default:"—" }} @@ -465,7 +518,11 @@ {% elif item.status == 'identical' %} {% translate "Identique" %} {% elif item.status == 'db_only' %} - {% translate "DB uniquement" %} + {% if item.action_default == 'keep' %} + {% translate "DB uniquement" %} + {% else %} + {% translate "DB uniquement" %} + {% endif %} {% endif %} {% for diff in item.details_diff %}
@@ -478,8 +535,8 @@ {% if item.status == 'db_only' %} {% else %} @@ -619,7 +691,7 @@ {% empty %} - {% translate "Aucun câble détecté." %} + {% translate "Aucun câble détecté." %} {% endfor %} @@ -831,103 +903,6 @@
- - {% endif %} @@ -937,8 +912,15 @@ {{ intersection.lon|json_script:"intersection_lon" }} {{ intersection.lat|json_script:"intersection_lat" }} {{ dxf_poles|json_script:"dxf_poles" }} -{{ dxf_files|json_script:"dxf_files" }} -{{ georeferenced_documents_json|json_script:"georeferencedDocumentsData" }} + +{{ dxf_files|json_script:"dxfFilesData" }} + {% endif %} {% endblock head %} @@ -77,45 +654,78 @@ {# ─── Modal de filtre ──────────────────────────────────────────────────────── #}