feat: support DXF-based pole alignment in traffic light imports

This commit is contained in:
kdeterme 2026-09-19 15:53:52 +02:00
parent 6e4a304930
commit d73a9e482d
2 changed files with 612 additions and 37 deletions

View file

@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n %} {% load i18n %}
{% load l10n %}
{% load static %} {% load static %}
{% block content %} {% block content %}
@ -147,10 +148,10 @@
<!-- Hidden input fields for poles coordinates --> <!-- Hidden input fields for poles coordinates -->
{% for item in poles %} {% for item in poles %}
{% if item.status != 'db_only' %} {% if item.status != 'db_only' %}
<input type="hidden" name="lon_pole_{{ item.code }}" id="lon_pole_{{ item.code }}" value=""> <input type="hidden" name="lon_pole_{{ item.code }}" id="lon_pole_{{ item.code }}" value="{{ item.new_lon|default_if_none:''|unlocalize }}">
<input type="hidden" name="lat_pole_{{ item.code }}" id="lat_pole_{{ item.code }}" value=""> <input type="hidden" name="lat_pole_{{ item.code }}" id="lat_pole_{{ item.code }}" value="{{ item.new_lat|default_if_none:''|unlocalize }}">
<input type="hidden" name="x_3812_pole_{{ item.code }}" id="x_3812_pole_{{ item.code }}" value=""> <input type="hidden" name="x_3812_pole_{{ item.code }}" id="x_3812_pole_{{ item.code }}" value="{{ item.new_x_3812|default_if_none:''|unlocalize }}">
<input type="hidden" name="y_3812_pole_{{ item.code }}" id="y_3812_pole_{{ item.code }}" value=""> <input type="hidden" name="y_3812_pole_{{ item.code }}" id="y_3812_pole_{{ item.code }}" value="{{ item.new_y_3812|default_if_none:''|unlocalize }}">
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -187,11 +188,22 @@
<i class="bi bi-dash-square me-1"></i> {% translate "Tout décocher" %} <i class="bi bi-dash-square me-1"></i> {% translate "Tout décocher" %}
</button> </button>
</div> </div>
<div class="btn-group btn-group-sm rounded-3 shadow-sm me-1" role="group">
<button type="button" class="btn btn-outline-success" id="btn-batch-update" title="{% translate 'Définir toutes les décisions sur Mettre à jour' %}">
<i class="bi bi-pencil-square me-1"></i> {% translate "Tout Mettre à jour" %}
</button>
<button type="button" class="btn btn-outline-warning text-dark" id="btn-batch-replace" title="{% translate 'Définir toutes les décisions sur Remplacer' %}">
<i class="bi bi-arrow-repeat me-1"></i> {% translate "Tout Remplacer" %}
</button>
</div>
<button type="button" class="btn btn-success btn-sm rounded-3 shadow-sm px-3" onclick="triggerGlobalAction('update_all')">
<i class="bi bi-arrow-repeat me-1"></i> {% translate "Tout mettre à jour (Conserver existant + MAJ)" %}
</button>
<button type="button" class="btn btn-outline-danger btn-sm rounded-3 shadow-sm px-3" onclick="triggerGlobalAction('replace_all')"> <button type="button" class="btn btn-outline-danger btn-sm rounded-3 shadow-sm px-3" onclick="triggerGlobalAction('replace_all')">
<i class="bi bi-trash3-fill"></i> {% translate "Tout remplacer (Archiver l'existant + Recréer)" %} <i class="bi bi-trash3-fill me-1"></i> {% translate "Tout remplacer (Archiver l'existant + Recréer)" %}
</button> </button>
<button type="submit" class="btn btn-primary btn-sm rounded-3 shadow-sm px-4"> <button type="submit" class="btn btn-primary btn-sm rounded-3 shadow-sm px-4">
<i class="bi bi-check2-circle"></i> {% translate "Appliquer les décisions" %} <i class="bi bi-check2-circle me-1"></i> {% translate "Appliquer les décisions" %}
</button> </button>
</div> </div>
</div> </div>
@ -253,6 +265,7 @@
<th>{% translate "Code Asset" %}</th> <th>{% translate "Code Asset" %}</th>
<th>{% translate "Nom" %}</th> <th>{% translate "Nom" %}</th>
<th>{% translate "Statut" %}</th> <th>{% translate "Statut" %}</th>
<th>{% translate "Position / Géométrie" %}</th>
<th>{% translate "Modèle Déduit (Excel)" %}</th> <th>{% translate "Modèle Déduit (Excel)" %}</th>
<th>{% translate "Modèle Actuel (DB)" %}</th> <th>{% translate "Modèle Actuel (DB)" %}</th>
<th>{% translate "Décision" %}</th> <th>{% translate "Décision" %}</th>
@ -269,7 +282,7 @@
</td> </td>
<td> <td>
{% if item.status == 'new' %} {% if item.status == 'new' %}
<span class="badge bg-success">{% translate "Nouveau" %}</span> <span class="badge bg-success">{% translate "Nouveau" %}</span>
{% elif item.status == 'modified' %} {% elif item.status == 'modified' %}
<span class="badge bg-warning text-dark">{% translate "Modifié" %}</span> <span class="badge bg-warning text-dark">{% translate "Modifié" %}</span>
{% elif item.status == 'identical' %} {% elif item.status == 'identical' %}
@ -283,6 +296,45 @@
</div> </div>
{% endfor %} {% endfor %}
</td> </td>
<td id="geom_cell_pole_{{ item.code }}">
{% if item.geom_modified %}
<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1">
<i class="bi bi-arrows-move"></i>
{% if item.distance_diff is not None %}
{% translate "Décalage" %} {{ item.distance_diff }} m
{% else %}
{% translate "Position modifiée" %}
{% endif %}
</span>
<button type="button" class="btn btn-xs btn-outline-primary ms-1 show-pole-on-map-btn" data-code="{{ item.code }}" title="{% translate 'Voir et ajuster sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% elif item.new_lon is not None %}
{% if item.status == 'new' %}
<span class="badge bg-success d-inline-flex align-items-center gap-1">
<i class="bi bi-geo-alt-fill"></i> {% if item.new_x_3812 %}DXF{% else %}{% translate "Plan" %}{% endif %}
</span>
{% elif item.status == 'db_only' %}
<span class="badge bg-secondary d-inline-flex align-items-center gap-1">
<i class="bi bi-database"></i> DB
</span>
{% else %}
<span class="badge bg-light text-muted border d-inline-flex align-items-center gap-1">
<i class="bi bi-check2"></i> {% translate "Inchangée" %}
</span>
{% endif %}
<button type="button" class="btn btn-xs btn-outline-secondary ms-1 show-pole-on-map-btn" data-code="{{ item.code }}" title="{% translate 'Voir sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% else %}
<span class="badge bg-danger d-inline-flex align-items-center gap-1">
<i class="bi bi-geo-alt"></i> {% translate "Non positionné" %}
</span>
<button type="button" class="btn btn-xs btn-outline-primary ms-1 show-pole-on-map-btn" data-code="{{ item.code }}" title="{% translate 'Positionner sur la carte' %}">
<i class="bi bi-crosshair"></i>
</button>
{% endif %}
</td>
<td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td> <td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td>
<td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td> <td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td>
<td> <td>
@ -302,6 +354,7 @@
<option value="keep">{% translate "Conserver existant" %}</option> <option value="keep">{% translate "Conserver existant" %}</option>
{% elif item.status == 'identical' %} {% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option> <option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
{% endif %} {% endif %}
</select> </select>
@ -328,7 +381,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="7" class="text-center py-4 text-muted">{% translate "Aucun poteau détecté." %}</td> <td colspan="9" class="text-center py-4 text-muted">{% translate "Aucun poteau détecté." %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -544,6 +597,7 @@
<th>{% translate "Code Asset" %}</th> <th>{% translate "Code Asset" %}</th>
<th>{% translate "Nom" %}</th> <th>{% translate "Nom" %}</th>
<th>{% translate "Statut" %}</th> <th>{% translate "Statut" %}</th>
<th>{% translate "Géométrie (DB)" %}</th>
<th>{% translate "Modèle Déduit (Excel)" %}</th> <th>{% translate "Modèle Déduit (Excel)" %}</th>
<th>{% translate "Modèle Actuel (DB)" %}</th> <th>{% translate "Modèle Actuel (DB)" %}</th>
<th>{% translate "Décision" %}</th> <th>{% translate "Décision" %}</th>
@ -574,6 +628,15 @@
</div> </div>
{% endfor %} {% endfor %}
</td> </td>
<td>
{% if item.has_db_geom %}
<span class="badge bg-info text-dark d-inline-flex align-items-center gap-1">
<i class="bi bi-bezier2"></i> {{ item.db_geom_length }} m
</span>
{% else %}
<span class="badge bg-light text-muted border">{% translate "Non tracé" %}</span>
{% endif %}
</td>
<td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td> <td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td>
<td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td> <td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td>
<td> <td>
@ -593,6 +656,7 @@
<option value="keep">{% translate "Conserver existant" %}</option> <option value="keep">{% translate "Conserver existant" %}</option>
{% elif item.status == 'identical' %} {% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option> <option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
{% endif %} {% endif %}
</select> </select>
@ -619,7 +683,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="8" class="text-center py-4 text-muted">{% translate "Aucun câble détecté." %}</td> <td colspan="9" class="text-center py-4 text-muted">{% translate "Aucun câble détecté." %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -984,6 +1048,11 @@
document.getElementById('global_action_input').value = 'replace_all'; document.getElementById('global_action_input').value = 'replace_all';
document.getElementById('combined-import-form').submit(); 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();
}
} }
} }
</script> </script>
@ -1031,6 +1100,45 @@ document.addEventListener('DOMContentLoaded', () => {
btnUncheckAllGlobal.addEventListener('click', () => setAllCheckboxesState(false)); 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 // 2. Interactive Switch Logic between Table View and Map View
const tableView = document.getElementById('table-view'); const tableView = document.getElementById('table-view');
const mapView = document.getElementById('map-view'); const mapView = document.getElementById('map-view');
@ -1089,7 +1197,17 @@ document.addEventListener('DOMContentLoaded', () => {
{ {
code: "{{ item.code|escapejs }}", code: "{{ item.code|escapejs }}",
short_code: "{{ item.short_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 %} {% endfor %}
]; ];
@ -1409,9 +1527,41 @@ document.addEventListener('DOMContentLoaded', () => {
loadDxfBackground(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(); 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) { function getSnappedCoords(lon, lat) {
let bestDistance = Infinity; let bestDistance = Infinity;
let bestCoords = [lon, lat]; let bestCoords = [lon, lat];
@ -1443,10 +1593,34 @@ document.addEventListener('DOMContentLoaded', () => {
badge.textContent = `{% translate "Positionné" %}`; badge.textContent = `{% translate "Positionné" %}`;
} }
document.getElementById(`lon_pole_${code}`).value = lon; const inputLon = document.getElementById(`lon_pole_${code}`);
document.getElementById(`lat_pole_${code}`).value = lat; if (inputLon) inputLon.value = lon;
document.getElementById(`x_3812_pole_${code}`).value = x_3812 !== undefined && x_3812 !== null ? x_3812 : ""; const inputLat = document.getElementById(`lat_pole_${code}`);
document.getElementById(`y_3812_pole_${code}`).value = y_3812 !== undefined && y_3812 !== null ? y_3812 : ""; 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 = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-geo-alt-fill"></i> {% translate "Positionné sur carte" %}${distText}</span> <button type="button" class="btn btn-xs btn-outline-primary ms-1 show-pole-on-map-btn" data-code="${code}" title="{% translate 'Voir et ajuster sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
}
}
redrawCablesOnMap(); redrawCablesOnMap();
updatePolesCounter(); updatePolesCounter();
@ -1462,7 +1636,7 @@ document.addEventListener('DOMContentLoaded', () => {
placePoleMarker(activePlacementPole, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1]); 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]) { if (placedPoles[code]) {
placedPoles[code].marker.remove(); placedPoles[code].marker.remove();
} }
@ -1516,11 +1690,13 @@ document.addEventListener('DOMContentLoaded', () => {
listItem.classList.add('bg-light'); listItem.classList.add('bg-light');
} }
const unplaced = polesList.filter(p => p.status !== 'db_only' && !placedPoles[p.code]); if (autoAdvance) {
if (unplaced.length > 0) { const unplaced = polesList.filter(p => p.status !== 'db_only' && !placedPoles[p.code]);
activatePlacementMode(unplaced[0].code); if (unplaced.length > 0) {
} else { activatePlacementMode(unplaced[0].code);
deactivatePlacementMode(); } else {
deactivatePlacementMode();
}
} }
} }

View file

@ -1922,6 +1922,54 @@ def get_base_dxf_name(filename):
return f"{base_cleaned}{ext}".lower() 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) @check_thematic_access('trafficlights', edit_permission_required=True)
def import_dxf(request, intersection_id): def import_dxf(request, intersection_id):
intersection = get_object_or_404(TrafficLightIntersection, pk=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 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 ── # ── 1. POLES COMPARISON ──
existing_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')} existing_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')}
compared_poles = [] 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"]: for dp in deduced["poles"]:
code = dp["code"] code = dp["code"]
short_code = dp["short_code"]
model_name = dp["model"] model_name = dp["model"]
db_model = TrafficLightPoleModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightPoleModel.objects.first() 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: if code in existing_poles:
ep = existing_poles[code] ep = existing_poles[code]
# Since cross plans do not specify pole physical models (height, color), keep existing DB model ep_lon = ep.lon
status = 'identical' 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({ compared_poles.append({
'code': code, 'code': code,
'short_code': dp['short_code'], 'short_code': short_code,
'name': ep.name_fr or dp.get('name', dp['short_code']), 'name': ep.name_fr or dp.get('name', short_code),
'status': status, '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_model_name': ep.model.name_fr if ep.model else '',
'db_id': ep.id, '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 # remove from processed
existing_poles.pop(code) existing_poles.pop(code)
else: else:
details_diff = []
if dxf_match:
details_diff.append(_("Positionné via DXF"))
compared_poles.append({ compared_poles.append({
'code': code, 'code': code,
'short_code': dp['short_code'], 'short_code': short_code,
'name': dp.get('name', dp['short_code']), 'name': dp.get('name', short_code),
'status': 'new', 'status': 'new',
'deduced_model_name': model_name, 'deduced_model_name': model_name,
'db_model_name': '', 'db_model_name': '',
'db_id': None, '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) # Add remaining existing active poles (orphans)
for code, ep in existing_poles.items(): 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({ compared_poles.append({
'code': code, 'code': code,
'short_code': code.split('_')[-1] if '_' in code else 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': '', 'deduced_model_name': '',
'db_model_name': ep.model.name_fr if ep.model else '', 'db_model_name': ep.model.name_fr if ep.model else '',
'db_id': ep.id, '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 ── # ── 2. CABLES COMPARISON ──
existing_cables = {c.code: c for c in TrafficLightCable.objects.filter(intersection=intersection, status='active')} existing_cables = {c.code: c for c in TrafficLightCable.objects.filter(intersection=intersection, status='active')}
compared_cables = [] compared_cables = []
@ -4138,6 +4296,7 @@ def import_cross_plan(request, intersection_id):
ec = existing_cables[code] ec = existing_cables[code]
is_diff = ec.model_id != db_model.id is_diff = ec.model_id != db_model.id
status = 'modified' if is_diff else 'identical' status = 'modified' if is_diff else 'identical'
has_geom = ec.geom is not None
compared_cables.append({ compared_cables.append({
'code': code, 'code': code,
'name': ec.name_fr or dc['name'], '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_model_name': ec.model.name_fr if ec.model else '',
'db_id': ec.id, 'db_id': ec.id,
'details_diff': [f"Modèle : {ec.model.name_fr if ec.model else 'Aucun'} → {model_name}"] if is_diff else [], '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) existing_cables.pop(code)
else: else:
@ -4158,9 +4319,12 @@ def import_cross_plan(request, intersection_id):
'db_model_name': '', 'db_model_name': '',
'db_id': None, 'db_id': None,
'details_diff': [], '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(): for code, ec in existing_cables.items():
has_geom = ec.geom is not None
compared_cables.append({ compared_cables.append({
'code': code, 'code': code,
'name': ec.name_fr or code, 'name': ec.name_fr or code,
@ -4168,7 +4332,9 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': '', 'deduced_model_name': '',
'db_model_name': ec.model.name_fr if ec.model else '', 'db_model_name': ec.model.name_fr if ec.model else '',
'db_id': ec.id, '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 ── # ── 3. LANTERNS COMPARISON ──
@ -4526,8 +4692,8 @@ def import_cross_plan(request, intersection_id):
'cable_models': cable_models, 'cable_models': cable_models,
'step': 'preview', 'step': 'preview',
'thematic_code': 'trafficlights', 'thematic_code': 'trafficlights',
'dxf_file': "", 'dxf_file': active_dxf_file,
'dxf_poles': [], 'dxf_poles': active_dxf_poles,
'existing_poles_geojson': existing_poles_geojson, 'existing_poles_geojson': existing_poles_geojson,
'dxf_files': dxf_files, 'dxf_files': dxf_files,
'georeferenced_documents_json': georeferenced_documents_json, 'georeferenced_documents_json': georeferenced_documents_json,
@ -4621,6 +4787,40 @@ def integrate_cross_plan(request, intersection_id):
) )
return m 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 update_pole_geometry(pole, code):
def clean_float(val): def clean_float(val):
if not val: if not val:
@ -4635,9 +4835,22 @@ def integrate_cross_plan(request, intersection_id):
x_3812 = clean_float(request.POST.get(f"x_3812_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}")) 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: 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 django.contrib.gis.geos import Point as DjangoPoint
from pyproj import Transformer
pole.geom = DjangoPoint(x_3812, y_3812, srid=3812) 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.lon = lon
pole.lat = lat pole.lat = lat
elif lon is not None and lat is not None: 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.") 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: else:
# We iterate over each asset category, checking the user choices from the form post parameters # We iterate over each asset category, checking the user choices from the form post parameters