feat: implement traffic light radar support including models, views, templates, and tests

This commit is contained in:
Karl Determe 2026-08-25 16:02:51 +02:00
parent 6fe9908a11
commit ba268f5e2a
4 changed files with 91 additions and 18 deletions

View file

@ -3,6 +3,7 @@
# --------------------- # ---------------------
from django.contrib.gis.db import models from django.contrib.gis.db import models
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -299,23 +300,23 @@ class TrafficLightPole(TrafficLightAbstractGeoAsset):
@property @property
def sorted_lanterns(self): def sorted_lanterns(self):
return self.lanterns.order_by('code') return self.lanterns.exclude(status='archived').order_by('code')
@property @property
def sorted_accessories(self): def sorted_accessories(self):
return self.accessories.order_by('code') return self.accessories.exclude(status='archived').order_by('code')
@property @property
def sorted_radars(self): def sorted_radars(self):
return self.radars.order_by('code') return self.radars.exclude(status='archived').order_by('code')
@property @property
def sorted_detectors(self): def sorted_detectors(self):
return self.detectors.order_by('code') return self.detectors.exclude(status='archived').order_by('code')
@property @property
def sorted_signpanels(self): def sorted_signpanels(self):
return self.signpanels_attached.order_by('code') return self.signpanels_attached.exclude(status='archived').order_by('code')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -408,19 +409,24 @@ class TrafficLightController(TrafficLightAbstractGeoAsset):
@property @property
def sorted_hardwares(self): def sorted_hardwares(self):
return self.hardwares.order_by('code') return self.hardwares.exclude(status='archived').order_by('code')
@property @property
def sorted_detectors(self): def sorted_detectors(self):
return self.detectors.order_by('code') from assets.models.trafficlights import TrafficLightDetector
if self.intersection_id:
return TrafficLightDetector.objects.filter(
Q(controller=self) | Q(intersection_id=self.intersection_id)
).exclude(status='archived').distinct().order_by('code')
return self.detectors.exclude(status='archived').order_by('code')
@property @property
def sorted_network_hardwares(self): def sorted_network_hardwares(self):
return self.network_hardwares.order_by('code') return self.network_hardwares.exclude(status='archived').order_by('code')
@property @property
def sorted_voltage_hardwares(self): def sorted_voltage_hardwares(self):
return self.voltage_hardwares.order_by('code') return self.voltage_hardwares.exclude(status='archived').order_by('code')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -516,6 +522,10 @@ class TrafficLightDetector(TrafficLightAbstractGeoAsset):
self.intersection = self.pole.intersection self.intersection = self.pole.intersection
elif self.controller and self.controller.intersection and not self.intersection_id: elif self.controller and self.controller.intersection and not self.intersection_id:
self.intersection = self.controller.intersection self.intersection = self.controller.intersection
if not self.controller_id and self.intersection_id:
active_ctrl = TrafficLightController.objects.filter(intersection_id=self.intersection_id, status='active').first()
if active_ctrl:
self.controller = active_ctrl
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Meta: class Meta:
@ -760,7 +770,7 @@ class TrafficLightRadarPole(TrafficLightAbstractGeoAsset):
@property @property
def sorted_radars(self): def sorted_radars(self):
return self.radars.order_by('code') return self.radars.exclude(status='archived').order_by('code')
@property @property
def sorted_signpanels(self): def sorted_signpanels(self):

View file

@ -677,7 +677,7 @@
aria-controls="collapseHardware{{ ctrl.id }}"> aria-controls="collapseHardware{{ ctrl.id }}">
<i class="bi bi-motherboard me-2 text-secondary"></i> <i class="bi bi-motherboard me-2 text-secondary"></i>
{% translate "Hardwares du contrôleur" %} {% translate "Hardwares du contrôleur" %}
<span class="badge bg-secondary ms-2">{{ ctrl.hardwares.count }}</span> <span class="badge bg-secondary ms-2">{{ ctrl.sorted_hardwares|length }}</span>
</button> </button>
</h2> </h2>
<div id="collapseHardware{{ ctrl.id }}" class="accordion-collapse collapse" <div id="collapseHardware{{ ctrl.id }}" class="accordion-collapse collapse"
@ -717,7 +717,7 @@
aria-controls="collapseDetectors{{ ctrl.id }}"> aria-controls="collapseDetectors{{ ctrl.id }}">
<i class="bi bi-radar me-2 text-secondary"></i> <i class="bi bi-radar me-2 text-secondary"></i>
{% translate "Détecteurs" %} {% translate "Détecteurs" %}
<span class="badge bg-secondary ms-2">{{ ctrl.detectors.count }}</span> <span class="badge bg-secondary ms-2">{{ ctrl.sorted_detectors|length }}</span>
</button> </button>
</h2> </h2>
<div id="collapseDetectors{{ ctrl.id }}" class="accordion-collapse collapse" <div id="collapseDetectors{{ ctrl.id }}" class="accordion-collapse collapse"
@ -732,6 +732,7 @@
<i class="bi bi-radar text-secondary small"></i> <i class="bi bi-radar text-secondary small"></i>
<a href="{% url 'assets:trafficlights_assets_detail' 'trafficlightdetector' det.id %}" class="fw-medium text-decoration-none">{{ det.code }}</a> <a href="{% url 'assets:trafficlights_assets_detail' 'trafficlightdetector' det.id %}" class="fw-medium text-decoration-none">{{ det.code }}</a>
{% if det.name %}<span class="text-muted small ms-1">{{ det.name }}</span>{% endif %} {% if det.name %}<span class="text-muted small ms-1">{{ det.name }}</span>{% endif %}
{% if det.pole %}<span class="badge bg-light text-secondary border small"><i class="bi bi-sign-stop-lights me-1"></i>{{ det.pole.code }}</span>{% endif %}
<span class="ms-auto d-flex gap-1 align-items-center"> <span class="ms-auto d-flex gap-1 align-items-center">
{% if det.hardware %}<span class="badge bg-light text-dark border">{{ det.hardware }}</span>{% endif %} {% if det.hardware %}<span class="badge bg-light text-dark border">{{ det.hardware }}</span>{% endif %}
{% if det.status != 'active' %}<span class="badge {% if det.status == 'archived' %}bg-secondary{% else %}bg-warning text-dark{% endif %}">{{ det.get_status_display }}</span>{% endif %} {% if det.status != 'active' %}<span class="badge {% if det.status == 'archived' %}bg-secondary{% else %}bg-warning text-dark{% endif %}">{{ det.get_status_display }}</span>{% endif %}
@ -757,7 +758,7 @@
aria-controls="collapseNetwork{{ ctrl.id }}"> aria-controls="collapseNetwork{{ ctrl.id }}">
<i class="bi bi-hdd-network me-2 text-secondary"></i> <i class="bi bi-hdd-network me-2 text-secondary"></i>
{% translate "Hardware réseau" %} {% translate "Hardware réseau" %}
<span class="badge bg-secondary ms-2">{{ ctrl.network_hardwares.count }}</span> <span class="badge bg-secondary ms-2">{{ ctrl.sorted_network_hardwares|length }}</span>
</button> </button>
</h2> </h2>
<div id="collapseNetwork{{ ctrl.id }}" class="accordion-collapse collapse" <div id="collapseNetwork{{ ctrl.id }}" class="accordion-collapse collapse"
@ -797,7 +798,7 @@
aria-controls="collapseVoltage{{ ctrl.id }}"> aria-controls="collapseVoltage{{ ctrl.id }}">
<i class="bi bi-lightning-charge me-2 text-secondary"></i> <i class="bi bi-lightning-charge me-2 text-secondary"></i>
{% translate "Hardware Tension" %} {% translate "Hardware Tension" %}
<span class="badge bg-secondary ms-2">{{ ctrl.voltage_hardwares.count }}</span> <span class="badge bg-secondary ms-2">{{ ctrl.sorted_voltage_hardwares|length }}</span>
</button> </button>
</h2> </h2>
<div id="collapseVoltage{{ ctrl.id }}" class="accordion-collapse collapse" <div id="collapseVoltage{{ ctrl.id }}" class="accordion-collapse collapse"

View file

@ -1323,6 +1323,51 @@ class TrafficLightCrossPlanImportTest(TestCase):
self.assertIsNotNone(new_active) self.assertIsNotNone(new_active)
self.assertEqual(old_lantern.replaced_by, new_active) self.assertEqual(old_lantern.replaced_by, new_active)
def test_modify_action_updates_code_and_properties(self):
from assets.models import TrafficLightPole, TrafficLightLantern, TrafficLightDetector
pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_A01",
status="active"
)
old_lantern = TrafficLightLantern.objects.create(
pole=pole,
code="SWB01_A01_LAN01",
model=self.lantern_model,
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)
# Post integrate with action="modify"
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_lantern_SWB01_A01_LAN01_T1": "on",
"action_lantern_SWB01_A01_LAN01_T1": "modify",
"db_id_lantern_SWB01_A01_LAN01_T1": str(old_lantern.id)
})
self.assertEqual(response_integrate.status_code, 302)
# Old lantern must have its code updated in-place without creating a duplicate
old_lantern.refresh_from_db()
self.assertEqual(old_lantern.status, "active")
self.assertEqual(old_lantern.code, "SWB01_A01_LAN01_T1")
def test_other_associated_assets_preview_and_integration(self): def test_other_associated_assets_preview_and_integration(self):
from assets.models import TrafficLightPole, TrafficLightController, TrafficLightControllerHardware, TrafficLightAccessory from assets.models import TrafficLightPole, TrafficLightController, TrafficLightControllerHardware, TrafficLightAccessory
from assets.models.sign import SignPanel from assets.models.sign import SignPanel

View file

@ -34,8 +34,16 @@ from common.models import Thematic, UserConfig, UserContractAccess, UserThematic
from common.utils import check_thematic_access from common.utils import check_thematic_access
from interventions.models import InterventionLocation from interventions.models import InterventionLocation
from interventions.views.drafts import InterventionDraftManager from interventions.views.drafts import InterventionDraftManager
from assets.models import TrafficLightIntersection, TrafficLightIntersectionDocument, TrafficLightController, TrafficLightPole, TrafficLightCable, TrafficLightLantern, TrafficLightDetector, TrafficLightElectricalCabinet, TrafficLightRadar, TrafficLightRadarPole, TrafficLightProgramming from assets.models import (
from assets.models import AssetCategory, TrafficLightCableModel, TrafficLightControllerModel, TrafficLightDetectorModel, TrafficLightLanternModel, TrafficLightPoleModel, TrafficLightElectricalCabinetModel, TrafficLightContract, TrafficLightRadarModel TrafficLightIntersection, TrafficLightIntersectionDocument, TrafficLightController,
TrafficLightPole, TrafficLightCable, TrafficLightLantern, TrafficLightDetector,
TrafficLightElectricalCabinet, TrafficLightRadar, TrafficLightRadarPole, TrafficLightProgramming,
TrafficLightAccessory, TrafficLightAccessoryModel, TrafficLightNetworkHardware,
TrafficLightVoltageHardware, TrafficLightControllerHardware,
AssetCategory, TrafficLightCableModel, TrafficLightControllerModel,
TrafficLightDetectorModel, TrafficLightLanternModel, TrafficLightPoleModel,
TrafficLightElectricalCabinetModel, TrafficLightContract, TrafficLightRadarModel
)
from assets.forms import IntersectionDocumentForm from assets.forms import IntersectionDocumentForm
from assets.permissions import get_allowed_actions_for_thematic, can_edit_location_for_thematic from assets.permissions import get_allowed_actions_for_thematic, can_edit_location_for_thematic
from contracts.models import Contract from contracts.models import Contract
@ -4411,7 +4419,7 @@ def import_cross_plan(request, intersection_id):
pass pass
try: try:
for acc in TrafficLightAccessory.objects.filter(intersection=intersection, status='active').select_related('model', 'pole'): for acc in TrafficLightAccessory.objects.filter(Q(intersection=intersection) | Q(pole__intersection=intersection), status='active').distinct().select_related('model', 'pole'):
other_assets.append({ other_assets.append({
'type': 'accessory', 'type': 'accessory',
'category_label': _("Accessoire"), 'category_label': _("Accessoire"),
@ -4430,7 +4438,7 @@ def import_cross_plan(request, intersection_id):
pass pass
try: try:
for rad in TrafficLightRadar.objects.filter(intersection=intersection, status='active').select_related('model', 'trafficlights_pole', 'pole'): for rad in TrafficLightRadar.objects.filter(Q(intersection=intersection) | Q(trafficlights_pole__intersection=intersection) | Q(pole__intersection=intersection), status='active').distinct().select_related('model', 'trafficlights_pole', 'pole'):
sup = rad.trafficlights_pole.code if rad.trafficlights_pole else (rad.pole.code if rad.pole else _("Carrefour")) sup = rad.trafficlights_pole.code if rad.trafficlights_pole else (rad.pole.code if rad.pole else _("Carrefour"))
other_assets.append({ other_assets.append({
'type': 'radar', 'type': 'radar',
@ -4798,6 +4806,7 @@ def integrate_cross_plan(request, intersection_id):
p.save() p.save()
created_poles[code] = p created_poles[code] = p
elif action == "modify" and existing: elif action == "modify" and existing:
existing.code = code
existing.model = db_model existing.model = db_model
existing.name_fr = custom_name existing.name_fr = custom_name
existing.name_nl = custom_name existing.name_nl = custom_name
@ -4902,6 +4911,7 @@ def integrate_cross_plan(request, intersection_id):
update_cable_geometry(c, code) update_cable_geometry(c, code)
c.save() c.save()
elif action == "modify" and existing: elif action == "modify" and existing:
existing.code = code
existing.model = db_model existing.model = db_model
existing.name_fr = custom_name existing.name_fr = custom_name
existing.name_nl = custom_name existing.name_nl = custom_name
@ -4983,6 +4993,9 @@ def integrate_cross_plan(request, intersection_id):
updated_by=request.user updated_by=request.user
) )
elif action == "modify" and existing: elif action == "modify" and existing:
existing.code = code
if pole:
existing.pole = pole
existing.model = db_model existing.model = db_model
existing.name_fr = custom_name existing.name_fr = custom_name
existing.name_nl = custom_name existing.name_nl = custom_name
@ -5061,8 +5074,12 @@ def integrate_cross_plan(request, intersection_id):
updated_by=request.user updated_by=request.user
) )
elif action == "modify" and existing: elif action == "modify" and existing:
existing.code = code
if pole:
existing.pole = pole
existing.model = db_model existing.model = db_model
existing.name = custom_name existing.name = custom_name
existing.phases = dd["phases"]
existing.updated_by = request.user existing.updated_by = request.user
existing.save() existing.save()
elif action == "replace" and existing: elif action == "replace" and existing: