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" %}
{% load i18n %}
{% load l10n %}
{% load static %}
{% block content %}
@ -147,10 +148,10 @@
<!-- Hidden input fields for poles coordinates -->
{% for item in poles %}
{% if item.status != 'db_only' %}
<input type="hidden" name="lon_pole_{{ item.code }}" id="lon_pole_{{ item.code }}" value="">
<input type="hidden" name="lat_pole_{{ item.code }}" id="lat_pole_{{ item.code }}" value="">
<input type="hidden" name="x_3812_pole_{{ item.code }}" id="x_3812_pole_{{ item.code }}" value="">
<input type="hidden" name="y_3812_pole_{{ item.code }}" id="y_3812_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="{{ item.new_lat|default_if_none:''|unlocalize }}">
<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="{{ item.new_y_3812|default_if_none:''|unlocalize }}">
{% endif %}
{% endfor %}
@ -187,11 +188,22 @@
<i class="bi bi-dash-square me-1"></i> {% translate "Tout décocher" %}
</button>
</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')">
<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 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>
</div>
</div>
@ -253,6 +265,7 @@
<th>{% translate "Code Asset" %}</th>
<th>{% translate "Nom" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Position / Géométrie" %}</th>
<th>{% translate "Modèle Déduit (Excel)" %}</th>
<th>{% translate "Modèle Actuel (DB)" %}</th>
<th>{% translate "Décision" %}</th>
@ -269,7 +282,7 @@
</td>
<td>
{% if item.status == 'new' %}
<span class="badge bg-success">{% translate "Nouveau" %}</span>
<span class="badge bg-success">{% translate "Nouveau" %}</span>
{% elif item.status == 'modified' %}
<span class="badge bg-warning text-dark">{% translate "Modifié" %}</span>
{% elif item.status == 'identical' %}
@ -283,6 +296,45 @@
</div>
{% endfor %}
</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.db_model_name|default:"—" }}</span></td>
<td>
@ -302,6 +354,7 @@
<option value="keep">{% translate "Conserver existant" %}</option>
{% elif item.status == 'identical' %}
<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>
{% endif %}
</select>
@ -328,7 +381,7 @@
</tr>
{% empty %}
<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>
{% endfor %}
</tbody>
@ -544,6 +597,7 @@
<th>{% translate "Code Asset" %}</th>
<th>{% translate "Nom" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Géométrie (DB)" %}</th>
<th>{% translate "Modèle Déduit (Excel)" %}</th>
<th>{% translate "Modèle Actuel (DB)" %}</th>
<th>{% translate "Décision" %}</th>
@ -574,6 +628,15 @@
</div>
{% endfor %}
</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.db_model_name|default:"—" }}</span></td>
<td>
@ -593,6 +656,7 @@
<option value="keep">{% translate "Conserver existant" %}</option>
{% elif item.status == 'identical' %}
<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>
{% endif %}
</select>
@ -619,7 +683,7 @@
</tr>
{% empty %}
<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>
{% endfor %}
</tbody>
@ -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();
}
}
}
</script>
@ -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 = `<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();
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();
}
}
}

View file

@ -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