feat: add UI indicators and handling for modified pole and cable geometries in traffic lights import

This commit is contained in:
kdeterme 2026-09-21 11:29:06 +02:00
parent 28d6664fbd
commit 3f402e3d6a
3 changed files with 285 additions and 43 deletions

View file

@ -597,7 +597,7 @@
<th>{% translate "Code Asset" %}</th>
<th>{% translate "Nom" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Géométrie (DB)" %}</th>
<th>{% translate "Géométrie" %}</th>
<th>{% translate "Modèle Déduit (Excel)" %}</th>
<th>{% translate "Modèle Actuel (DB)" %}</th>
<th>{% translate "Décision" %}</th>
@ -628,8 +628,12 @@
</div>
{% endfor %}
</td>
<td>
{% if item.has_db_geom %}
<td id="geom_cell_cable_{{ 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> {% translate "Tracé modifié" %}
</span>
{% elif 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>
@ -1235,6 +1239,8 @@ document.addEventListener('DOMContentLoaded', () => {
{
code: "{{ item.code|escapejs }}",
name: "{{ item.name|escapejs }}",
status: "{{ item.status|escapejs }}",
geom_modified: {{ item.geom_modified|yesno:"true,false" }},
connected_poles: {{ item.connected_poles|safe|default:"[]" }}
},
{% endfor %}
@ -1265,9 +1271,26 @@ document.addEventListener('DOMContentLoaded', () => {
let activePlacementPole = null;
let activeEditCable = null;
const placedPoles = {};
const cableGeometries = {};
let cableHandles = [];
const cableGeometries = {};
function markCableAsModified(code) {
const selectAction = document.querySelector(`select[name="action_cable_${code}"]`);
if (selectAction) {
if (selectAction.querySelector('option[value="modify"]')) {
selectAction.value = 'modify';
} else if (selectAction.querySelector('option[value="add"]')) {
selectAction.value = 'add';
}
}
const cb = document.querySelector(`input[name="selected_cable_${code}"]`);
if (cb) cb.checked = true;
const geomCell = document.getElementById(`geom_cell_cable_${code}`);
if (geomCell) {
geomCell.innerHTML = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-arrows-move"></i> {% translate "Tracé modifié" %}</span>`;
}
}
const snapCandidates = initialDxfPoles.map(dp => [dp.lon, dp.lat, dp.x_3812, dp.y_3812]);
@ -1530,7 +1553,7 @@ document.addEventListener('DOMContentLoaded', () => {
// 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);
placePoleMarker(p.code, p.new_lon, p.new_lat, p.new_x_3812, p.new_y_3812, false, true);
}
});
@ -1585,7 +1608,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('placed-poles-count').textContent = `${placed} / ${total}`;
}
function markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812) {
function markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812, isInitialLoad = false) {
const badge = document.getElementById(`georef_badge_${code}`);
if (badge) {
badge.classList.remove('bg-danger');
@ -1602,24 +1625,49 @@ document.addEventListener('DOMContentLoaded', () => {
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';
// If not initial load (i.e. user moved or placed the pole interactively), update decision and geom cell
if (!isInitialLoad) {
const poleMeta = polesList.find(p => p.code === code);
if (poleMeta) {
const selectAction = document.querySelector(`select[name="action_pole_${code}"]`);
const geomCell = document.getElementById(`geom_cell_pole_${code}`);
const hasDbPos = (poleMeta.db_x_3812 !== null && poleMeta.db_y_3812 !== null && x_3812 !== null && y_3812 !== null);
let dist = hasDbPos ? Math.sqrt((poleMeta.db_x_3812 - x_3812)**2 + (poleMeta.db_y_3812 - y_3812)**2) : null;
const isIdenticalPos = (hasDbPos && dist < 0.05);
if (isIdenticalPos && poleMeta.status === 'identical') {
// Position identical and attributes identical: propose "Ne rien faire"
if (selectAction && selectAction.querySelector('option[value="keep"]')) {
selectAction.value = 'keep';
}
if (geomCell) {
geomCell.innerHTML = `<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> <button type="button" class="btn btn-xs btn-outline-secondary ms-1 show-pole-on-map-btn" data-code="${code}" title="{% translate 'Voir sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
}
} else {
// Position changed or newly placed: propose "Mise à jour"
if (selectAction) {
if (selectAction.querySelector('option[value="modify"]')) {
selectAction.value = 'modify';
} else if (selectAction.querySelector('option[value="add"]')) {
selectAction.value = 'add';
}
}
const cb = document.querySelector(`input[name="selected_pole_${code}"]`);
if (cb) cb.checked = true;
if (geomCell) {
let distText = (dist !== null) ? ` (${dist.toFixed(1)} m)` : "";
geomCell.innerHTML = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-arrows-move"></i> {% translate "Position modifiée" %}${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>`;
}
// Any cable connected to this pole also has its geometry modified
cablesList.forEach(c => {
if (c.connected_poles && c.connected_poles.includes(code)) {
markCableAsModified(c.code);
}
});
}
}
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();
@ -1633,10 +1681,10 @@ document.addEventListener('DOMContentLoaded', () => {
const originalCoords = [e.lngLat.lng, e.lngLat.lat];
const snapped = getSnappedCoords(originalCoords[0], originalCoords[1]);
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], true, false);
});
function placePoleMarker(code, lon, lat, x_3812, y_3812, autoAdvance = true) {
function placePoleMarker(code, lon, lat, x_3812, y_3812, autoAdvance = true, isInitialLoad = false) {
if (placedPoles[code]) {
placedPoles[code].marker.remove();
}
@ -1678,10 +1726,10 @@ document.addEventListener('DOMContentLoaded', () => {
placedPoles[code].lat = snapped.coords[1];
placedPoles[code].x_3812 = snapped.lambert[0];
placedPoles[code].y_3812 = snapped.lambert[1];
markPoleAsGeoreferenced(code, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1]);
markPoleAsGeoreferenced(code, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1], false);
});
markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812);
markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812, isInitialLoad);
const mapPane = document.getElementById('map-poles-pane');
mapPane.querySelectorAll('.pole-config-item').forEach(item => item.classList.remove('bg-light'));
@ -1821,6 +1869,7 @@ document.addEventListener('DOMContentLoaded', () => {
cableGeometries[code][index] = snapped.coords;
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(code);
});
el.addEventListener('dblclick', function(e) {
@ -1829,6 +1878,7 @@ document.addEventListener('DOMContentLoaded', () => {
drawCableHandles(code);
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(code);
});
});
}
@ -1885,6 +1935,7 @@ document.addEventListener('DOMContentLoaded', () => {
drawCableHandles(activeEditCable);
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(activeEditCable);
});
map.on('dblclick', function(e) {
@ -1907,6 +1958,7 @@ document.addEventListener('DOMContentLoaded', () => {
drawCableHandles(activeEditCable);
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(activeEditCable);
});
// Hover mouse styles for active cable line
@ -1929,6 +1981,7 @@ document.addEventListener('DOMContentLoaded', () => {
drawCableHandles(activeEditCable);
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(activeEditCable);
}
});

View file

@ -2715,6 +2715,164 @@ class TrafficLightCrossPlanImportTest(TestCase):
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_import_cross_plan_default_decisions_identical_vs_modified(self):
"""
Verify that:
- When pole & cable position and model are identical: status is 'identical',
default proposed decision in HTML is 'Ne rien faire (Identique)'.
- When pole position changed (DXF shift): status is 'modified',
default proposed decision in HTML is 'Mettre à jour l'asset'.
- When connected pole moved, the cable status becomes 'modified',
default proposed decision in HTML is 'Mettre à jour l'asset'.
"""
import openpyxl
import tempfile
from unittest.mock import patch
from django.contrib.gis.geos import Point as DjangoPoint, LineString as DjangoLineString
from assets.models import (
TrafficLightPole, TrafficLightCable,
TrafficLightPoleModel, TrafficLightCableModel
)
from django.core.files.uploadedfile import SimpleUploadedFile
pole_model = TrafficLightPoleModel.objects.first()
cable_model = TrafficLightCableModel.objects.first()
# Pole A01: position will be identical (DXF matches DB)
pole_a = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_A01",
name_fr="Poteau A01",
geom=DjangoPoint(149200.0, 170500.0, srid=3812),
lon=4.350,
lat=50.840,
model=pole_model,
status="active"
)
# Pole B01: position will change (DXF differs by 2.0m)
pole_b = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_B01",
name_fr="Poteau B01",
geom=DjangoPoint(149300.0, 170600.0, srid=3812),
lon=4.351,
lat=50.841,
model=pole_model,
status="active"
)
# Cable 01: connects A01 and B01
cable_01 = TrafficLightCable.objects.create(
intersection=self.intersection,
code="SWB01_CAB01",
name_fr="Câble 01",
geom=DjangoLineString([(149200.0, 170500.0), (149300.0, 170600.0)], srid=3812),
model=cable_model,
status="active"
)
# Create mock workbook
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "kruisjesplan"
ws.cell(row=1, column=1, value="Sleutel:")
ws.cell(row=1, column=2, value="SWB01")
ws.cell(row=5, column=1, value=f"Câble 01 {cable_model.name_fr}")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="A01")
ws.cell(row=7, column=6, value="B01")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="A")
ws.cell(row=8, column=4, value="V")
ws.cell(row=8, column=5, value="X")
ws.cell(row=8, column=6, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
# Mock DXF poles: A01 identical (0.0m diff), B01 shifted by 2.0m
mock_dxf_poles = [
{'label': 'A01', 'x_3812': 149200.0, 'y_3812': 170500.0, 'lon': 4.350, 'lat': 50.840},
{'label': 'B01', 'x_3812': 149302.0, 'y_3812': 170600.0, 'lon': 4.35102, 'lat': 50.841},
]
try:
self.client.force_login(self.user)
with open(tmp_path, "rb") as f:
uploaded_file = SimpleUploadedFile("plan_test_decisions.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
with patch('assets.views.trafficlights.resolve_dxf_file_path', return_value='/dummy/test.dxf'), \
patch('assets.views.trafficlights.parse_dxf_poles', return_value=mock_dxf_poles):
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
response = self.client.post(url_import, {
"cross_plan_file_upload": uploaded_file,
"selected_dxf": "test.dxf"
})
self.assertEqual(response.status_code, 200)
# Context assertions
poles = response.context["poles"]
cables = response.context["cables"]
p_a = next(p for p in poles if p["code"] == "SWB01_A01")
p_b = next(p for p in poles if p["code"] == "SWB01_B01")
c_1 = next(c for c in cables if c["code"] == "SWB01_CAB01")
# Pole A: Identical position & model
self.assertEqual(p_a["status"], "identical")
self.assertFalse(p_a["geom_modified"])
# Pole B: Position shifted
self.assertEqual(p_b["status"], "modified")
self.assertTrue(p_b["geom_modified"])
self.assertEqual(p_b["distance_diff"], 2.0)
# Cable 1: Connected pole B01 moved -> Cable position is modified!
self.assertEqual(c_1["status"], "modified")
self.assertTrue(c_1["geom_modified"])
self.assertTrue(any("déplacement de poteaux connectés" in d for d in c_1["details_diff"]))
# HTML rendered assertions
content = response.content.decode("utf-8")
# Pole A should have 'keep' (Ne rien faire) selected
self.assertIn('name="action_pole_SWB01_A01"', content)
self.assertRegex(content, r'<select name="action_pole_SWB01_A01"[^>]*>[\s\S]*?<option value="keep" selected>')
# Pole B should have 'modify' (Mettre à jour l'asset) selected
self.assertIn('name="action_pole_SWB01_B01"', content)
self.assertRegex(content, r'<select name="action_pole_SWB01_B01"[^>]*>[\s\S]*?<option value="modify" selected>')
# Cable 1 should have 'modify' (Mettre à jour l'asset) selected because B01 moved
self.assertIn('name="action_cable_SWB01_CAB01"', content)
self.assertRegex(content, r'<select name="action_cable_SWB01_CAB01"[^>]*>[\s\S]*?<option value="modify" selected>')
# Check that cable geometry cell rendered
self.assertIn('id="geom_cell_cable_SWB01_CAB01"', content)
# Test POST integrate with 'keep' on pole A01: verifies position and properties are untouched
url_integrate = reverse("assets:integrate_cross_plan", args=[self.intersection.id])
response_integrate = self.client.post(url_integrate, {
"cross_plan_filename": response.context["cross_plan_filename"],
"selected_pole_SWB01_A01": "on",
"action_pole_SWB01_A01": "keep",
"name_pole_SWB01_A01": "Ignored Custom Name",
"x_3812_pole_SWB01_A01": "999999.0",
"y_3812_pole_SWB01_A01": "999999.0",
})
self.assertEqual(response_integrate.status_code, 302)
pole_a.refresh_from_db()
# Must remain untouched because action was "keep" (Ne rien faire)
self.assertEqual(pole_a.name_fr, "Poteau A01")
self.assertEqual(pole_a.geom.x, 149200.0)
self.assertEqual(pole_a.geom.y, 170500.0)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
class AssetDefaultPositionTest(TestCase):
def test_assign_default_position_hierarchy(self):

View file

@ -4287,16 +4287,48 @@ def import_cross_plan(request, intersection_id):
# ── 2. CABLES COMPARISON ──
existing_cables = {c.code: c for c in TrafficLightCable.objects.filter(intersection=intersection, status='active')}
compared_cables = []
# Track poles that moved or are newly placed to see if connected cable paths changed
modified_pole_codes = {
p['code'] for p in compared_poles
if p.get('geom_modified') or p.get('status') == 'new'
}
modified_pole_short_codes = {
p['short_code'] for p in compared_poles
if p.get('geom_modified') or p.get('status') == 'new'
}
for dc in deduced["cables"]:
code = dc["code"]
model_name = dc["model_name"]
db_model = TrafficLightCableModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightCableModel.objects.first()
connected = dc.get("connected_poles", [])
has_connected_moved = any(
p_code in modified_pole_codes or p_code.split('_')[-1] in modified_pole_short_codes
for p_code in connected
)
if code in existing_cables:
ec = existing_cables[code]
is_diff = ec.model_id != db_model.id
status = 'modified' if is_diff else 'identical'
has_geom = ec.geom is not None
is_diff = (ec.model_id != db_model.id) if (ec.model and db_model) else False
details_diff = []
if is_diff:
details_diff.append(_("Modèle : %(db_m)s → %(new_m)s") % {
'db_m': ec.model.name_fr if ec.model else 'Aucun',
'new_m': model_name
})
geom_modified = False
if has_connected_moved:
geom_modified = True
details_diff.append(_("Position modifiée : déplacement de poteaux connectés"))
elif not has_geom and len(connected) >= 2:
geom_modified = True
details_diff.append(_("Nouveau tracé déduit des poteaux"))
status = 'modified' if (is_diff or geom_modified) else 'identical'
compared_cables.append({
'code': code,
'name': ec.name_fr or dc['name'],
@ -4304,13 +4336,17 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': model_name,
'db_model_name': ec.model.name_fr if ec.model else '',
'db_id': ec.id,
'details_diff': [f"Modèle : {ec.model.name_fr if ec.model else 'Aucun'} → {model_name}"] if is_diff else [],
'connected_poles': dc.get("connected_poles", []),
'details_diff': details_diff,
'connected_poles': connected,
'has_db_geom': has_geom,
'geom_modified': geom_modified,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
})
existing_cables.pop(code)
else:
details_diff = []
if len(connected) >= 2:
details_diff.append(_("Tracé déduit des poteaux"))
compared_cables.append({
'code': code,
'name': dc['name'],
@ -4318,9 +4354,10 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': model_name,
'db_model_name': '',
'db_id': None,
'details_diff': [],
'connected_poles': dc.get("connected_poles", []),
'details_diff': details_diff,
'connected_poles': connected,
'has_db_geom': False,
'geom_modified': False,
'db_geom_length': None,
})
for code, ec in existing_cables.items():
@ -4334,6 +4371,7 @@ def import_cross_plan(request, intersection_id):
'db_id': ec.id,
'details_diff': [_("Absent du fichier Excel (présent en DB)")],
'has_db_geom': has_geom,
'geom_modified': False,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
})
@ -5291,12 +5329,8 @@ def integrate_cross_plan(request, intersection_id):
except Exception:
pass
else:
# 'keep'
# 'keep' (Ne rien faire)
if existing:
existing.name_fr = custom_name
existing.name_nl = custom_name
update_pole_geometry(existing, code)
existing.save()
created_poles[code] = existing
# Handle poles present in DB only (orphans)
@ -5381,12 +5415,9 @@ def integrate_cross_plan(request, intersection_id):
existing.replaced_by = new_cable
existing.save()
else:
# 'keep'
# 'keep' (Ne rien faire)
if existing:
existing.name_fr = custom_name
existing.name_nl = custom_name
update_cable_geometry(existing, code)
existing.save()
pass
for key in request.POST.keys():
if key.startswith("action_cable_db_"):