From d73a9e482dd8f345cb53c5c98bd3fe60ac11b329 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sat, 19 Sep 2026 15:53:52 +0200 Subject: [PATCH] feat: support DXF-based pole alignment in traffic light imports --- .../trafficlights_import_cross_plan.html | 216 ++++++++- loko/assets/views/trafficlights.py | 433 +++++++++++++++++- 2 files changed, 612 insertions(+), 37 deletions(-) diff --git a/loko/assets/templates/assets/trafficlights_import_cross_plan.html b/loko/assets/templates/assets/trafficlights_import_cross_plan.html index 665686d..5cef657 100644 --- a/loko/assets/templates/assets/trafficlights_import_cross_plan.html +++ b/loko/assets/templates/assets/trafficlights_import_cross_plan.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% load i18n %} +{% load l10n %} {% load static %} {% block content %} @@ -147,10 +148,10 @@ {% for item in poles %} {% if item.status != 'db_only' %} - - - - + + + + {% endif %} {% endfor %} @@ -187,11 +188,22 @@ {% translate "Tout décocher" %} +
+ + +
+ @@ -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 %} @@ -544,6 +597,7 @@ {% translate "Code Asset" %} {% translate "Nom" %} {% translate "Statut" %} + {% translate "Géométrie (DB)" %} {% translate "Modèle Déduit (Excel)" %} {% translate "Modèle Actuel (DB)" %} {% translate "Décision" %} @@ -574,6 +628,15 @@ {% endfor %} + + {% if item.has_db_geom %} + + {{ item.db_geom_length }} m + + {% else %} + {% translate "Non tracé" %} + {% endif %} + {{ item.deduced_model_name|default:"—" }} {{ item.db_model_name|default:"—" }} @@ -593,6 +656,7 @@ {% elif item.status == 'identical' %} + {% endif %} @@ -619,7 +683,7 @@ {% empty %} - {% translate "Aucun câble détecté." %} + {% translate "Aucun câble détecté." %} {% endfor %} @@ -984,6 +1048,11 @@ document.getElementById('global_action_input').value = 'replace_all'; document.getElementById('combined-import-form').submit(); } + } else if (actionType === 'update_all') { + if (confirm("{% translate 'Voulez-vous mettre à jour les assets existants sans les archiver, et créer les nouveaux assets présents dans le plan ? Les géométries et modèles modifiés seront appliqués.' %}")) { + document.getElementById('global_action_input').value = 'update_all'; + document.getElementById('combined-import-form').submit(); + } } } @@ -1031,6 +1100,45 @@ document.addEventListener('DOMContentLoaded', () => { btnUncheckAllGlobal.addEventListener('click', () => setAllCheckboxesState(false)); } + // Batch Decision Buttons (Tout Mettre à jour / Tout Remplacer) + const btnBatchUpdate = document.getElementById('btn-batch-update'); + if (btnBatchUpdate) { + btnBatchUpdate.addEventListener('click', () => { + const allDecisionSelects = document.querySelectorAll('select[name^="action_pole_"], select[name^="action_cable_"], select[name^="action_lantern_"], select[name^="action_detector_"]'); + allDecisionSelects.forEach(sel => { + if (sel.name.includes('_db_')) { + if (sel.querySelector('option[value="keep"]')) sel.value = 'keep'; + } else { + if (sel.querySelector('option[value="modify"]')) { + sel.value = 'modify'; + } else if (sel.querySelector('option[value="add"]')) { + sel.value = 'add'; + } else if (sel.querySelector('option[value="keep"]')) { + sel.value = 'keep'; + } + } + }); + }); + } + + const btnBatchReplace = document.getElementById('btn-batch-replace'); + if (btnBatchReplace) { + btnBatchReplace.addEventListener('click', () => { + const allDecisionSelects = document.querySelectorAll('select[name^="action_pole_"], select[name^="action_cable_"], select[name^="action_lantern_"], select[name^="action_detector_"]'); + allDecisionSelects.forEach(sel => { + if (sel.name.includes('_db_')) { + if (sel.querySelector('option[value="archive"]')) sel.value = 'archive'; + } else { + if (sel.querySelector('option[value="replace"]')) { + sel.value = 'replace'; + } else if (sel.querySelector('option[value="add"]')) { + sel.value = 'add'; + } + } + }); + }); + } + // 2. Interactive Switch Logic between Table View and Map View const tableView = document.getElementById('table-view'); const mapView = document.getElementById('map-view'); @@ -1089,7 +1197,17 @@ document.addEventListener('DOMContentLoaded', () => { { code: "{{ item.code|escapejs }}", short_code: "{{ item.short_code|escapejs }}", - status: "{{ item.status|escapejs }}" + status: "{{ item.status|escapejs }}", + new_lon: {% if item.new_lon is not None %}{{ item.new_lon|default_if_none:''|unlocalize }}{% else %}null{% endif %}, + new_lat: {% if item.new_lat is not None %}{{ item.new_lat|default_if_none:''|unlocalize }}{% else %}null{% endif %}, + new_x_3812: {% if item.new_x_3812 is not None %}{{ item.new_x_3812|default_if_none:''|unlocalize }}{% else %}null{% endif %}, + new_y_3812: {% if item.new_y_3812 is not None %}{{ item.new_y_3812|default_if_none:''|unlocalize }}{% else %}null{% endif %}, + db_lon: {% if item.db_lon is not None %}{{ item.db_lon|default_if_none:''|unlocalize }}{% else %}null{% endif %}, + db_lat: {% if item.db_lat is not None %}{{ item.db_lat|default_if_none:''|unlocalize }}{% else %}null{% endif %}, + db_x_3812: {% if item.db_x_3812 is not None %}{{ item.db_x_3812|default_if_none:''|unlocalize }}{% else %}null{% endif %}, + db_y_3812: {% if item.db_y_3812 is not None %}{{ item.db_y_3812|default_if_none:''|unlocalize }}{% else %}null{% endif %}, + geom_modified: {{ item.geom_modified|yesno:"true,false" }}, + distance_diff: {% if item.distance_diff is not None %}{{ item.distance_diff|default_if_none:''|unlocalize }}{% else %}null{% endif %} }, {% endfor %} ]; @@ -1408,10 +1526,42 @@ document.addEventListener('DOMContentLoaded', () => { if (window.dxfFile && window.dxfFile !== "") { loadDxfBackground(window.dxfFile); } + + // Automatically place markers for poles that have known coordinates (e.g. from DXF or previous georeferencing) + polesList.forEach(p => { + if (p.status !== 'db_only' && p.new_lon !== null && p.new_lat !== null) { + placePoleMarker(p.code, p.new_lon, p.new_lat, p.new_x_3812, p.new_y_3812, false); + } + }); updatePolesCounter(); }); + // Delegated click handler for "Voir sur la carte" buttons in table + document.addEventListener('click', function(e) { + const btn = e.target.closest('.show-pole-on-map-btn'); + if (!btn) return; + e.preventDefault(); + const code = btn.dataset.code; + + tableView.classList.remove('d-block'); + tableView.classList.add('d-none'); + mapView.classList.remove('d-none'); + mapView.classList.add('d-block'); + + if (map) { + map.resize(); + const tabEl = document.getElementById('map-poles-tab'); + if (tabEl) new bootstrap.Tab(tabEl).show(); + + if (placedPoles[code]) { + map.flyTo({ center: [placedPoles[code].lon, placedPoles[code].lat], zoom: 19 }); + } else { + activatePlacementMode(code); + } + } + }); + function getSnappedCoords(lon, lat) { let bestDistance = Infinity; let bestCoords = [lon, lat]; @@ -1443,10 +1593,34 @@ document.addEventListener('DOMContentLoaded', () => { badge.textContent = `{% translate "Positionné" %}`; } - document.getElementById(`lon_pole_${code}`).value = lon; - document.getElementById(`lat_pole_${code}`).value = lat; - document.getElementById(`x_3812_pole_${code}`).value = x_3812 !== undefined && x_3812 !== null ? x_3812 : ""; - document.getElementById(`y_3812_pole_${code}`).value = y_3812 !== undefined && y_3812 !== null ? y_3812 : ""; + const inputLon = document.getElementById(`lon_pole_${code}`); + if (inputLon) inputLon.value = lon; + const inputLat = document.getElementById(`lat_pole_${code}`); + if (inputLat) inputLat.value = lat; + const inputX = document.getElementById(`x_3812_pole_${code}`); + if (inputX) inputX.value = (x_3812 !== undefined && x_3812 !== null) ? x_3812 : ""; + const inputY = document.getElementById(`y_3812_pole_${code}`); + if (inputY) inputY.value = (y_3812 !== undefined && y_3812 !== null) ? y_3812 : ""; + + // Dynamically update the table row for this pole if position changed + const poleMeta = polesList.find(p => p.code === code); + if (poleMeta) { + const selectAction = document.querySelector(`select[name="action_pole_${code}"]`); + if (selectAction) { + if (selectAction.querySelector('option[value="modify"]')) { + selectAction.value = 'modify'; + } + } + const geomCell = document.getElementById(`geom_cell_pole_${code}`); + if (geomCell) { + let distText = ""; + if (poleMeta.db_x_3812 !== null && poleMeta.db_y_3812 !== null && x_3812 && y_3812) { + const d = Math.sqrt((poleMeta.db_x_3812 - x_3812)**2 + (poleMeta.db_y_3812 - y_3812)**2); + distText = ` (${d.toFixed(1)} m)`; + } + geomCell.innerHTML = ` {% translate "Positionné sur carte" %}${distText} `; + } + } redrawCablesOnMap(); updatePolesCounter(); @@ -1462,7 +1636,7 @@ document.addEventListener('DOMContentLoaded', () => { placePoleMarker(activePlacementPole, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1]); }); - function placePoleMarker(code, lon, lat, x_3812, y_3812) { + function placePoleMarker(code, lon, lat, x_3812, y_3812, autoAdvance = true) { if (placedPoles[code]) { placedPoles[code].marker.remove(); } @@ -1516,11 +1690,13 @@ document.addEventListener('DOMContentLoaded', () => { listItem.classList.add('bg-light'); } - const unplaced = polesList.filter(p => p.status !== 'db_only' && !placedPoles[p.code]); - if (unplaced.length > 0) { - activatePlacementMode(unplaced[0].code); - } else { - deactivatePlacementMode(); + if (autoAdvance) { + const unplaced = polesList.filter(p => p.status !== 'db_only' && !placedPoles[p.code]); + if (unplaced.length > 0) { + activatePlacementMode(unplaced[0].code); + } else { + deactivatePlacementMode(); + } } } diff --git a/loko/assets/views/trafficlights.py b/loko/assets/views/trafficlights.py index b270d07..276540e 100644 --- a/loko/assets/views/trafficlights.py +++ b/loko/assets/views/trafficlights.py @@ -1922,6 +1922,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) @@ -4078,43 +4126,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,9 +4270,20 @@ 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 = [] @@ -4138,6 +4296,7 @@ def import_cross_plan(request, intersection_id): 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 compared_cables.append({ 'code': code, 'name': ec.name_fr or dc['name'], @@ -4146,7 +4305,9 @@ def import_cross_plan(request, intersection_id): '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", []) + 'connected_poles': dc.get("connected_poles", []), + 'has_db_geom': has_geom, + 'db_geom_length': round(ec.geom.length, 1) if has_geom else None, }) existing_cables.pop(code) else: @@ -4158,9 +4319,12 @@ def import_cross_plan(request, intersection_id): 'db_model_name': '', 'db_id': None, 'details_diff': [], - 'connected_poles': dc.get("connected_poles", []) + 'connected_poles': dc.get("connected_poles", []), + 'has_db_geom': 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,7 +4332,9 @@ 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, + 'db_geom_length': round(ec.geom.length, 1) if has_geom else None, }) # ── 3. LANTERNS COMPARISON ── @@ -4526,8 +4692,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, @@ -4621,6 +4787,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: @@ -4634,10 +4834,23 @@ def integrate_cross_plan(request, intersection_id): lat = clean_float(request.POST.get(f"lat_pole_{code}")) 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 +4975,193 @@ 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"]) + existing = TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code, status='active').first() + if not existing: + db_id = request.POST.get(f"db_id_lantern_{code}") + if db_id: + existing = TrafficLightLantern.objects.filter(pk=db_id, 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