feat: add traffic light cross-plan import functionality with file upload and data preview/integration support

This commit is contained in:
Karl Determe 2026-08-25 13:25:37 +02:00
parent 442469f3e0
commit ac75712828
3 changed files with 508 additions and 33 deletions

View file

@ -215,6 +215,11 @@
<i class="bi bi-bezier2 me-2"></i> {% translate "Câbles" %} <span class="badge bg-secondary ms-1">{{ cables|length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link px-4 py-3 border-0 rounded-0" id="other-assets-tab" data-bs-toggle="tab" data-bs-target="#other-assets-pane" type="button" role="tab" aria-controls="other-assets-pane" aria-selected="false">
<i class="bi bi-diagram-3 me-2"></i> {% translate "Autres équipements" %} <span class="badge bg-secondary ms-1">{{ other_assets|length }}</span>
</button>
</li>
</ul>
</div>
@ -264,6 +269,11 @@
{% elif item.status == 'db_only' %}
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
{% endif %}
{% for diff in item.details_diff %}
<div class="small text-muted mt-1" style="font-size: 0.75rem; line-height: 1.25;">
<i class="bi bi-info-circle me-1"></i>{{ diff }}
</div>
{% endfor %}
</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>
@ -292,7 +302,13 @@
<td class="pe-4">
{% if item.status != 'db_only' %}
<select name="model_pole_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="">-- {% translate "Laisser par défaut" %} --</option>
<option value="">
{% if item.status == 'new' or item.status == 'modified' %}
-- {% translate "Modèle déduit" %} --
{% else %}
-- {% translate "Modèle actuel" %} --
{% endif %}
</option>
{% for m in pole_models %}
<option value="{{ m.id }}">{{ m.name_fr }}</option>
{% endfor %}
@ -348,6 +364,11 @@
{% elif item.status == 'db_only' %}
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
{% endif %}
{% for diff in item.details_diff %}
<div class="small text-muted mt-1" style="font-size: 0.75rem; line-height: 1.25;">
<i class="bi bi-info-circle me-1"></i>{{ diff }}
</div>
{% endfor %}
</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>
@ -376,7 +397,13 @@
<td class="pe-4">
{% if item.status != 'db_only' %}
<select name="model_lantern_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="">-- {% translate "Laisser par défaut" %} --</option>
<option value="">
{% if item.status == 'new' or item.status == 'modified' %}
-- {% translate "Modèle déduit" %} --
{% else %}
-- {% translate "Modèle actuel" %} --
{% endif %}
</option>
{% for m in lantern_models %}
<option value="{{ m.id }}">{{ m.name_fr }}</option>
{% endfor %}
@ -432,6 +459,11 @@
{% elif item.status == 'db_only' %}
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
{% endif %}
{% for diff in item.details_diff %}
<div class="small text-muted mt-1" style="font-size: 0.75rem; line-height: 1.25;">
<i class="bi bi-info-circle me-1"></i>{{ diff }}
</div>
{% endfor %}
</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>
@ -460,7 +492,13 @@
<td class="pe-4">
{% if item.status != 'db_only' %}
<select name="model_detector_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="">-- {% translate "Laisser par défaut" %} --</option>
<option value="">
{% if item.status == 'new' or item.status == 'modified' %}
-- {% translate "Modèle déduit" %} --
{% else %}
-- {% translate "Modèle actuel" %} --
{% endif %}
</option>
{% for m in detector_models %}
<option value="{{ m.id }}">{{ m.name_fr }}</option>
{% endfor %}
@ -484,7 +522,7 @@
<div class="tab-pane fade" id="cables-pane" role="tabpanel" aria-labelledby="cables-tab">
<div class="p-3 bg-light border-bottom d-flex align-items-center justify-content-between">
<span class="text-secondary small">
<i class="bi bi-info-circle"></i> {% translate "Vous pouvez tracer précisément les câbles d'interconnexion le long des tranchées de la carte." %}
<i class="bi bi-info-circle"></i> {% translate "Vous pouvez tracer précisément les câbles d'interconnexion le long des tranchées de la carte." %}
</span>
<button type="button" class="btn btn-outline-success btn-sm rounded-3 show-map-btn" data-target-tab="cables">
<i class="bi bi-bezier2 me-1"></i> {% translate "Tracer les câbles sur la carte" %}
@ -522,6 +560,11 @@
{% elif item.status == 'db_only' %}
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
{% endif %}
{% for diff in item.details_diff %}
<div class="small text-muted mt-1" style="font-size: 0.75rem; line-height: 1.25;">
<i class="bi bi-info-circle me-1"></i>{{ diff }}
</div>
{% endfor %}
</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>
@ -550,7 +593,13 @@
<td class="pe-4">
{% if item.status != 'db_only' %}
<select name="model_cable_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="">-- {% translate "Laisser par défaut" %} --</option>
<option value="">
{% if item.status == 'new' or item.status == 'modified' %}
-- {% translate "Modèle déduit" %} --
{% else %}
-- {% translate "Modèle actuel" %} --
{% endif %}
</option>
{% for m in cable_models %}
<option value="{{ m.id }}">{{ m.name_fr }}</option>
{% endfor %}
@ -562,7 +611,74 @@
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-4 text-muted">{% translate "Aucun câble détecté." %}</td>
<td colspan="8" class="text-center py-4 text-muted">{% translate "Aucun câble détecté." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- 5. OTHER ASSOCIATED ASSETS PANEL -->
<div class="tab-pane fade" id="other-assets-pane" role="tabpanel" aria-labelledby="other-assets-tab">
<div class="p-3 bg-light border-bottom d-flex align-items-center justify-content-between">
<span class="text-secondary small">
<i class="bi bi-info-circle"></i> {% translate "Équipements associés existants en base (panneaux, caméras ITS, contrôleurs, armoires, etc.) non décrits dans le plan de croix Excel. Par défaut, ils sont conservés." %}
</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-4" style="width: 40px;"><input type="checkbox" id="select-all-other" class="form-check-input" checked></th>
<th>{% translate "Catégorie" %}</th>
<th>{% translate "Code Asset" %}</th>
<th>{% translate "Nom / Modèle" %}</th>
<th>{% translate "Support / Liaison" %}</th>
<th>{% translate "Statut DB" %}</th>
<th class="pe-4">{% translate "Décision" %}</th>
</tr>
</thead>
<tbody>
{% for item in other_assets %}
<tr>
<td class="ps-4">
<input type="checkbox" name="selected_other_{{ item.unique_key }}" class="form-check-input other-checkbox" checked>
</td>
<td>
<span class="badge {{ item.badge_class }}">{{ item.category_label }}</span>
</td>
<td class="font-weight-medium">
<code>{{ item.code }}</code>
<input type="hidden" name="id_other_{{ item.unique_key }}" value="{{ item.id }}">
</td>
<td>
<div class="fw-semibold">{{ item.name }}</div>
{% if item.model_name and item.model_name != "—" %}
<div class="small text-muted">{{ item.model_name }}</div>
{% endif %}
</td>
<td>
<code>{{ item.support }}</code>
</td>
<td>
<span class="badge bg-light text-secondary border">{% translate "En base (Actif)" %}</span>
{% for diff in item.details_diff %}
<div class="small text-muted mt-1" style="font-size: 0.75rem; line-height: 1.25;">
<i class="bi bi-info-circle me-1"></i>{{ diff }}
</div>
{% endfor %}
</td>
<td class="pe-4">
<select name="action_other_{{ item.unique_key }}" class="form-select form-select-sm rounded-2">
<option value="keep" selected>{% translate "Conserver (Par défaut)" %}</option>
<option value="archive">{% translate "Archiver" %}</option>
</select>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-4 text-muted">{% translate "Aucun autre équipement associé en base pour ce carrefour." %}</td>
</tr>
{% endfor %}
</tbody>
@ -883,6 +999,7 @@ document.addEventListener('DOMContentLoaded', () => {
bindSelectAll('select-all-lanterns', 'lantern-checkbox');
bindSelectAll('select-all-detectors', 'detector-checkbox');
bindSelectAll('select-all-cables', 'cable-checkbox');
bindSelectAll('select-all-other', 'other-checkbox');
// 2. Interactive Switch Logic between Table View and Map View
const tableView = document.getElementById('table-view');

View file

@ -1323,6 +1323,90 @@ class TrafficLightCrossPlanImportTest(TestCase):
self.assertIsNotNone(new_active)
self.assertEqual(old_lantern.replaced_by, new_active)
def test_other_associated_assets_preview_and_integration(self):
from assets.models import TrafficLightPole, TrafficLightController, TrafficLightControllerHardware, TrafficLightAccessory
from assets.models.sign import SignPanel
from assets.models.its import ITSGeoAsset
pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_A01",
status="active"
)
ctrl = TrafficLightController.objects.create(
intersection=self.intersection,
code="SWB01_C01",
status="active"
)
hw = TrafficLightControllerHardware.objects.create(
controller=ctrl,
code="SWB01_HW_1",
status="active"
)
sign = SignPanel.objects.create(
trafficlight_pole=pole,
code="SWB01_SP01",
status="active"
)
its = ITSGeoAsset.objects.create(
trafficlights_pole=pole,
code="SWB01_ITS01",
status="active"
)
acc = TrafficLightAccessory.objects.create(
intersection=self.intersection,
pole=pole,
code="SWB01_ACC01",
status="active"
)
self.client.force_login(self.user)
import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
real_excel_path = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files', '18ac47df841-2808f-DOC-23270.xlsx')
if not os.path.exists(real_excel_path):
self.skipTest("Real Excel file for test not found")
with open(real_excel_path, 'rb') as f:
excel_content = f.read()
uploaded_file = SimpleUploadedFile("test_cross_plan.xlsx", excel_content, content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
response = self.client.post(url_import, {"cross_plan_file_upload": uploaded_file})
self.assertEqual(response.status_code, 200)
self.assertIn("other_assets", response.context)
other_assets = response.context["other_assets"]
self.assertTrue(len(other_assets) >= 4)
# Test selective integration: archive controller, keep sign panel, replace pole
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"],
f"selected_other_controller_{ctrl.id}": "on",
f"action_other_controller_{ctrl.id}": "archive",
f"selected_other_signpanel_{sign.id}": "on",
f"action_other_signpanel_{sign.id}": "keep",
"selected_pole_SWB01_A01": "on",
"action_pole_SWB01_A01": "replace",
"db_id_pole_SWB01_A01": str(pole.id)
})
self.assertEqual(response_integrate.status_code, 302)
ctrl.refresh_from_db()
self.assertEqual(ctrl.status, "archived")
hw.refresh_from_db()
self.assertEqual(hw.status, "archived")
sign.refresh_from_db()
self.assertEqual(sign.status, "active")
pole.refresh_from_db()
self.assertEqual(pole.status, "archived")
# Sign panel should have been re-linked to new replacement pole
self.assertEqual(sign.trafficlight_pole, pole.replaced_by)
def test_parse_telecom_detectors(self):
import openpyxl
import tempfile

View file

@ -4080,17 +4080,17 @@ def import_cross_plan(request, intersection_id):
if code in existing_poles:
ep = existing_poles[code]
is_diff = ep.model_id != db_model.id
status = 'modified' if is_diff else 'identical'
# Since cross plans do not specify pole physical models (height, color), keep existing DB model
status = 'identical'
compared_poles.append({
'code': code,
'short_code': dp['short_code'],
'name': ep.name_fr or dp.get('name', dp['short_code']),
'status': status,
'deduced_model_name': model_name,
'deduced_model_name': ep.model.name_fr if ep.model else model_name,
'db_model_name': ep.model.name_fr if ep.model else '',
'db_id': ep.id,
'details_diff': f"Modèle différent (DB: {ep.model.name_fr if ep.model else 'Aucun'} vs Excel: {model_name})" if is_diff else ''
'details_diff': []
})
# remove from processed
existing_poles.pop(code)
@ -4103,7 +4103,7 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': model_name,
'db_model_name': '',
'db_id': None,
'details_diff': ''
'details_diff': []
})
# Add remaining existing active poles (orphans)
for code, ep in existing_poles.items():
@ -4115,7 +4115,7 @@ 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 (Sera conservé ou archivé)")
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
})
# ── 2. CABLES COMPARISON ──
@ -4137,7 +4137,7 @@ 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 différent (DB: {ec.model.name_fr if ec.model else 'Aucun'} vs Excel: {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", [])
})
existing_cables.pop(code)
@ -4149,7 +4149,7 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': model_name,
'db_model_name': '',
'db_id': None,
'details_diff': '',
'details_diff': [],
'connected_poles': dc.get("connected_poles", [])
})
for code, ec in existing_cables.items():
@ -4160,7 +4160,7 @@ 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")
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
})
# ── 3. LANTERNS COMPARISON ──
@ -4187,9 +4187,9 @@ def import_cross_plan(request, intersection_id):
details = []
if is_rename:
details.append(f"Renommer le code (DB: {el.code} vs Excel: {code})")
details.append(f"Code : {el.code} → {code}")
if is_model_diff:
details.append(f"Modèle différent (DB: {el.model.name_fr if el.model else 'Aucun'} vs Excel: {model_name})")
details.append(f"Modèle : {el.model.name_fr if el.model else 'Aucun'} → {model_name}")
compared_lanterns.append({
'code': code,
@ -4199,7 +4199,7 @@ def import_cross_plan(request, intersection_id):
'db_model_name': el.model.name_fr if el.model else '',
'db_id': el.id,
'pole_code': dl['pole_code'],
'details_diff': ", ".join(details) if details else ''
'details_diff': details
})
existing_lanterns.pop(match_key)
else:
@ -4211,7 +4211,7 @@ def import_cross_plan(request, intersection_id):
'db_model_name': '',
'db_id': None,
'pole_code': dl['pole_code'],
'details_diff': ''
'details_diff': []
})
for code, el in existing_lanterns.items():
compared_lanterns.append({
@ -4222,7 +4222,7 @@ def import_cross_plan(request, intersection_id):
'db_model_name': el.model.name_fr if el.model else '',
'db_id': el.id,
'pole_code': el.pole.code,
'details_diff': _("Absent du fichier Excel")
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
})
# ── 4. DETECTORS COMPARISON ──
@ -4248,9 +4248,9 @@ def import_cross_plan(request, intersection_id):
is_diff = ed.model_id != db_model.id
details = []
if match_key != code:
details.append(f"Renommer le code (DB: {match_key} vs Excel: {code})")
details.append(f"Code : {match_key} → {code}")
if is_diff:
details.append(f"Modèle différent (DB: {ed.model.name_fr if ed.model else 'Aucun'} vs Excel: {model_name})")
details.append(f"Modèle : {ed.model.name_fr if ed.model else 'Aucun'} → {model_name}")
status = 'modified' if (is_diff or match_key != code) else 'identical'
compared_detectors.append({
@ -4261,7 +4261,7 @@ def import_cross_plan(request, intersection_id):
'db_model_name': ed.model.name_fr if ed.model else '',
'db_id': ed.id,
'pole_code': dd['pole_code'],
'details_diff': ", ".join(details) if details else ''
'details_diff': details
})
existing_detectors.pop(match_key)
else:
@ -4273,7 +4273,7 @@ def import_cross_plan(request, intersection_id):
'db_model_name': '',
'db_id': None,
'pole_code': dd['pole_code'],
'details_diff': ''
'details_diff': []
})
for code, ed in existing_detectors.items():
compared_detectors.append({
@ -4284,9 +4284,212 @@ def import_cross_plan(request, intersection_id):
'db_model_name': ed.model.name_fr if ed.model else '',
'db_id': ed.id,
'pole_code': ed.pole.code if ed.pole else '',
'details_diff': _("Absent du fichier Excel")
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
})
# ── 5. OTHER ASSOCIATED ASSETS (NOT IN CROSS PLAN) ──
other_assets = []
try:
from assets.models.sign import SignPanel
for sp in SignPanel.objects.filter(trafficlight_pole__intersection=intersection, status='active').select_related('model', 'signpanel_type', 'trafficlight_pole'):
model_display = sp.model.name_fr if sp.model else (sp.signpanel_type.name_fr if sp.signpanel_type else (sp.signpanel_text or "—"))
other_assets.append({
'type': 'signpanel',
'category_label': _("Panneau de signalisation"),
'badge_class': 'bg-info text-dark',
'id': sp.id,
'unique_key': f"signpanel_{sp.id}",
'code': sp.code or f"SP_{sp.id}",
'name': sp.name_fr or sp.code or _("Panneau"),
'model_name': model_display,
'support': sp.trafficlight_pole.code if sp.trafficlight_pole else _("Poteau"),
'status': 'db_only',
'details_diff': [_("Présent en DB (attaché au poteau)")],
'action_default': 'keep',
})
except Exception:
pass
try:
from assets.models.its import ITSGeoAsset
for its in ITSGeoAsset.objects.filter(trafficlights_pole__intersection=intersection, status='active').select_related('model', 'trafficlights_pole'):
other_assets.append({
'type': 'itsasset',
'category_label': _("Caméra ANPR / ITS"),
'badge_class': 'bg-primary',
'id': its.id,
'unique_key': f"itsasset_{its.id}",
'code': its.code or f"ITS_{its.id}",
'name': its.name_fr or its.code or _("Équipement ITS"),
'model_name': its.model.name_fr if its.model else "—",
'support': its.trafficlights_pole.code if its.trafficlights_pole else _("Poteau"),
'status': 'db_only',
'details_diff': [_("Présent en DB (attaché au poteau)")],
'action_default': 'keep',
})
except Exception:
pass
try:
for c in TrafficLightController.objects.filter(intersection=intersection, status='active').select_related('model'):
hw_count = c.hardwares.filter(status='active').count()
diff_note = _("Présent en DB")
if hw_count > 0:
diff_note += f" ({hw_count} hardwares)"
other_assets.append({
'type': 'controller',
'category_label': _("Contrôleur"),
'badge_class': 'bg-secondary',
'id': c.id,
'unique_key': f"controller_{c.id}",
'code': c.code or f"CTRL_{c.id}",
'name': c.name_fr or c.code or _("Contrôleur"),
'model_name': c.model.name_fr if c.model else "—",
'support': _("Carrefour"),
'status': 'db_only',
'details_diff': [diff_note],
'action_default': 'keep',
})
except Exception:
pass
try:
for nh in TrafficLightNetworkHardware.objects.filter(intersection=intersection, controller__isnull=True, status='active'):
other_assets.append({
'type': 'networkhardware',
'category_label': _("Matériel Réseau"),
'badge_class': 'bg-dark',
'id': nh.id,
'unique_key': f"networkhardware_{nh.id}",
'code': nh.code or f"NH_{nh.id}",
'name': nh.name or nh.hardware or _("Matériel réseau"),
'model_name': nh.hardware or "—",
'support': _("Carrefour"),
'status': 'db_only',
'details_diff': [_("Présent en DB (autonome)")],
'action_default': 'keep',
})
except Exception:
pass
try:
for vh in TrafficLightVoltageHardware.objects.filter(intersection=intersection, controller__isnull=True, status='active'):
other_assets.append({
'type': 'voltagehardware',
'category_label': _("Matériel Tension"),
'badge_class': 'bg-warning text-dark',
'id': vh.id,
'unique_key': f"voltagehardware_{vh.id}",
'code': vh.code or f"VH_{vh.id}",
'name': vh.name or vh.hardware or _("Matériel tension"),
'model_name': vh.hardware or "—",
'support': vh.controller.code if vh.controller else _("Carrefour"),
'status': 'db_only',
'details_diff': [_("Présent en DB (lié au contrôleur)")],
'action_default': 'keep',
})
except Exception:
pass
try:
for ec in TrafficLightElectricalCabinet.objects.filter(intersection=intersection, status='active').select_related('model'):
other_assets.append({
'type': 'electricalcabinet',
'category_label': _("Armoire électrique"),
'badge_class': 'bg-secondary',
'id': ec.id,
'unique_key': f"electricalcabinet_{ec.id}",
'code': ec.code or f"CAB_{ec.id}",
'name': ec.name_fr or ec.code or _("Armoire"),
'model_name': ec.model.name_fr if ec.model else (ec.ean_code or "—"),
'support': _("Carrefour"),
'status': 'db_only',
'details_diff': [_("Présent en DB")],
'action_default': 'keep',
})
except Exception:
pass
try:
for acc in TrafficLightAccessory.objects.filter(intersection=intersection, status='active').select_related('model', 'pole'):
other_assets.append({
'type': 'accessory',
'category_label': _("Accessoire"),
'badge_class': 'bg-light text-dark border',
'id': acc.id,
'unique_key': f"accessory_{acc.id}",
'code': acc.code or f"ACC_{acc.id}",
'name': acc.name_fr or acc.code or _("Accessoire"),
'model_name': acc.model.name_fr if acc.model else "—",
'support': acc.pole.code if acc.pole else _("Carrefour"),
'status': 'db_only',
'details_diff': [_("Présent en DB")],
'action_default': 'keep',
})
except Exception:
pass
try:
for rad in TrafficLightRadar.objects.filter(intersection=intersection, status='active').select_related('model', 'trafficlights_pole', 'pole'):
sup = rad.trafficlights_pole.code if rad.trafficlights_pole else (rad.pole.code if rad.pole else _("Carrefour"))
other_assets.append({
'type': 'radar',
'category_label': _("Radar"),
'badge_class': 'bg-danger',
'id': rad.id,
'unique_key': f"radar_{rad.id}",
'code': rad.code or f"RAD_{rad.id}",
'name': rad.name_fr or rad.code or _("Radar"),
'model_name': rad.model.name_fr if rad.model else "—",
'support': sup,
'status': 'db_only',
'details_diff': [_("Présent en DB")],
'action_default': 'keep',
})
except Exception:
pass
try:
for prog in TrafficLightProgramming.objects.filter(intersection=intersection, status='active'):
other_assets.append({
'type': 'programming',
'category_label': _("Programmation"),
'badge_class': 'bg-info text-dark',
'id': prog.id,
'unique_key': f"programming_{prog.id}",
'code': prog.code or f"PROG_{prog.id}",
'name': prog.plan or prog.index or _("Programmation"),
'model_name': prog.index or "—",
'support': _("Carrefour"),
'status': 'db_only',
'details_diff': [_("Présent en DB")],
'action_default': 'keep',
})
except Exception:
pass
try:
from assets.models.publiclighting import PublicLightingLightFixture
for lf in PublicLightingLightFixture.objects.filter(traffic_light_pole__intersection=intersection, status='active').select_related('model', 'traffic_light_pole'):
other_assets.append({
'type': 'lightfixture',
'category_label': _("Luminaire EP"),
'badge_class': 'bg-warning text-dark',
'id': lf.id,
'unique_key': f"lightfixture_{lf.id}",
'code': lf.code or f"LF_{lf.id}",
'name': lf.name_fr or lf.code or _("Luminaire"),
'model_name': lf.model.name_fr if lf.model else "—",
'support': lf.traffic_light_pole.code if lf.traffic_light_pole else _("Poteau"),
'status': 'db_only',
'details_diff': [_("Présent en DB (attaché au poteau)")],
'action_default': 'keep',
})
except Exception:
pass
other_assets.sort(key=lambda x: (x['category_label'], x['code']))
# Sort compared lists alphabetically by code for preview
compared_poles.sort(key=lambda x: x['code'])
compared_cables.sort(key=lambda x: x['code'])
@ -4308,6 +4511,7 @@ def import_cross_plan(request, intersection_id):
'cables': compared_cables,
'lanterns': compared_lanterns,
'detectors': compared_detectors,
'other_assets': other_assets,
'pole_models': pole_models,
'lantern_models': lantern_models,
'detector_models': detector_models,
@ -4479,6 +4683,22 @@ def integrate_cross_plan(request, intersection_id):
p.save()
created_poles[dp["code"]] = p
# Re-link existing active attached assets (signs, ITS, accessories, radars, light fixtures) to new poles matching code
try:
from assets.models.sign import SignPanel
from assets.models.its import ITSGeoAsset
from assets.models.publiclighting import PublicLightingLightFixture
from assets.models.trafficlights import TrafficLightAccessory, TrafficLightRadar
for code, new_pole in created_poles.items():
SignPanel.objects.filter(trafficlight_pole__intersection=intersection, trafficlight_pole__code=code, status='active').update(trafficlight_pole=new_pole)
ITSGeoAsset.objects.filter(trafficlights_pole__intersection=intersection, trafficlights_pole__code=code, status='active').update(trafficlights_pole=new_pole)
TrafficLightAccessory.objects.filter(intersection=intersection, pole__code=code, status='active').update(pole=new_pole)
TrafficLightRadar.objects.filter(intersection=intersection, trafficlights_pole__code=code, status='active').update(trafficlights_pole=new_pole)
PublicLightingLightFixture.objects.filter(traffic_light_pole__intersection=intersection, traffic_light_pole__code=code, status='active').update(traffic_light_pole=new_pole)
except Exception:
pass
# Re-create all cables
for dc in deduced["cables"]:
db_model = resolve_cable_model(dc["model_name"])
@ -4545,14 +4765,6 @@ def integrate_cross_plan(request, intersection_id):
action = request.POST.get(f"action_pole_{code}", "keep")
else:
action = "keep"
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"])
db_id = request.POST.get(f"db_id_pole_{code}")
existing = None
if db_id:
@ -4560,6 +4772,16 @@ def integrate_cross_plan(request, intersection_id):
if not existing:
existing = TrafficLightPole.objects.filter(intersection=intersection, code=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:
if existing and existing.model:
db_model = existing.model
else:
db_model = resolve_pole_model(dp["model"])
custom_name = request.POST.get(f"name_pole_{code}", dp.get('name', dp['short_code']))
if action == "add" or (action == "replace" and not existing):
p = TrafficLightPole(
@ -4603,6 +4825,17 @@ def integrate_cross_plan(request, intersection_id):
existing.replaced_by = new_pole
existing.save()
created_poles[code] = new_pole
try:
from assets.models.sign import SignPanel
from assets.models.its import ITSGeoAsset
from assets.models.publiclighting import PublicLightingLightFixture
SignPanel.objects.filter(trafficlight_pole=existing).update(trafficlight_pole=new_pole)
ITSGeoAsset.objects.filter(trafficlights_pole=existing).update(trafficlights_pole=new_pole)
TrafficLightAccessory.objects.filter(pole=existing).update(pole=new_pole)
TrafficLightRadar.objects.filter(trafficlights_pole=existing).update(trafficlights_pole=new_pole)
PublicLightingLightFixture.objects.filter(traffic_light_pole=existing).update(traffic_light_pole=new_pole)
except Exception:
pass
else:
# 'keep'
if existing:
@ -4867,6 +5100,47 @@ def integrate_cross_plan(request, intersection_id):
status='archived', updated_by=request.user
)
# --- 5. Other associated assets integration ---
for key in request.POST.keys():
if key.startswith("action_other_"):
suffix = key.replace("action_other_", "")
if "_" in suffix:
asset_type, asset_id_str = suffix.split("_", 1)
is_selected = request.POST.get(f"selected_other_{suffix}") == "on"
action = request.POST.get(key, "keep")
if is_selected and action == "archive":
try:
asset_id = int(asset_id_str)
if asset_type == "signpanel":
from assets.models.sign import SignPanel
SignPanel.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "itsasset":
from assets.models.its import ITSGeoAsset
ITSGeoAsset.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "controller":
TrafficLightController.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
from assets.models.trafficlights import TrafficLightControllerHardware
TrafficLightControllerHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
TrafficLightNetworkHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
TrafficLightVoltageHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "networkhardware":
TrafficLightNetworkHardware.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "voltagehardware":
TrafficLightVoltageHardware.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "electricalcabinet":
TrafficLightElectricalCabinet.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "accessory":
TrafficLightAccessory.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "radar":
TrafficLightRadar.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "programming":
TrafficLightProgramming.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "lightfixture":
from assets.models.publiclighting import PublicLightingLightFixture
PublicLightingLightFixture.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
except (ValueError, TypeError, Exception):
pass
messages.success(request, "Les modifications d'assets ont été appliquées avec succès.")
# Cleanup parsed file