loko/loko/assets/tests.py
kdeterme 244b16b3d3 feat(trafficlights): add cable geometry copy, duplication modal and node editing hints in cross plan import
- Add explanatory guidelines for cable node operations (double-click segment to insert node, double-click node to delete, drag to reposition)
- Add floating reminder pill on map during cable editing mode
- Add "Copier tracé..." dropdown on cable cards to import trace from other cables
- Add "Appliquer aux autres câbles..." modal to duplicate active trace across multiple cables at once
- Add direct map jump button on cable rows in comparative table
- Include db_coords in compared_cables context to preserve pre-existing geometries
- Add tests and update translations (fr, nl, en)
2026-09-30 14:39:32 +02:00

5329 lines
225 KiB
Python

from django.test import TestCase, tag
from django.contrib.auth.models import User
from django.contrib.gis.geos import MultiPolygon, Polygon
from django.urls import reverse
from django.contrib.contenttypes.models import ContentType
import json
import os
from unittest import skipUnless
RUN_DXF_TESTS = os.getenv('RUN_DXF_TESTS', '0') == '1'
from common.models import Thematic, UserConfig, UserThematics
from assets.models import NatureLocation, TrafficLightIntersection, TrafficLightRadarModel, TrafficLightRadar, AssetCategory
from assets.models.buildings import PublicBuilding, BuildingAsset, BuildingEquipment
class NatureLocationsGeoJSONTest(TestCase):
def setUp(self):
# Create a thematic
self.thematic = Thematic.objects.create(
code="nature",
name_fr="Nature",
name_nl="Natuur"
)
# Create a user
self.user = User.objects.create_user(username="testuser", password="password123")
self.config = UserConfig.objects.create(user=self.user)
# Give thematic access to user
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic,
can_view_assets=True
)
# Create a NatureLocation in Brussels (around lat/lon: 50.85, 4.35)
# In SRID 3812, Brussels coordinates are around x=648000, y=670000.
# Let's create a small multipolygon around it.
poly = Polygon(((648000, 670000), (648100, 670000), (648100, 670100), (648000, 670100), (648000, 670000)), srid=3812)
multipoly = MultiPolygon(poly, srid=3812)
# Build the WGS84 geojson
geom_4326 = multipoly.clone()
geom_4326.transform(4326)
self.location = NatureLocation.objects.create(
code="LOC-001",
name_fr="Test Location",
geom=multipoly,
geojson=geom_4326.json,
is_highway=False
)
def test_nature_locations_geojson_with_bbox_intersection(self):
self.client.force_login(self.user)
# Coordinates in degrees (WGS84, SRID 4326):
# x=148000, y=170000 in Lambert 2008 (SRID 3812) is roughly lon=4.3508, lat=50.8465
# Let's verify that a bounding box around it intersects and returns the location
# A polygon in SRID 4326:
bbox = {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[
[4.34, 50.84],
[4.36, 50.84],
[4.36, 50.86],
[4.34, 50.86],
[4.34, 50.84]
]]
}
}
response = self.client.post(
reverse("assets:nature_locations_geojson"),
data=json.dumps({"filter_geom": bbox}),
content_type="application/json"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["type"], "FeatureCollection")
# Ensure our location is in the results
features = data["features"]
self.assertTrue(any(f["properties"]["code"] == "LOC-001" for f in features))
from django.test import override_settings
@override_settings(STORAGES={
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
})
class TrafficLightRadarTest(TestCase):
def setUp(self):
# Create a thematic for traffic lights
self.thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={
"name_fr": "Feux de signalisation",
"name_nl": "Verkeerslichten"
}
)
self.user = User.objects.create_user(username="testuser", password="password123")
self.config = UserConfig.objects.create(user=self.user, limit_assets_to_contracts=False)
# Give thematic access to user
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic,
can_view_assets=True
)
# Create category for radar
self.category, _ = AssetCategory.objects.get_or_create(
code="TL_RADAR_PREVENTIF",
defaults={
"name_fr": "Radar Préventif",
"name_nl": "Preventieve Radar",
"thematic": self.thematic
}
)
# Create a spatial point for the intersection
from django.contrib.gis.geos import Point
geom_intersection = Point(648000, 670000, srid=3812)
# Create intersection with is_radar=True
self.intersection = TrafficLightIntersection.objects.create(
code="ROM001",
name_fr="Test Radar Intersection",
geom=geom_intersection,
lon=4.3508,
lat=50.8465,
is_radar=True
)
# Create a model
self.radar_model = TrafficLightRadarModel.objects.create(
code="RAD-MODEL-01",
name_fr="Model 1",
category=self.category
)
# Create a radar pole
from assets.models.trafficlights import TrafficLightRadarPole
self.radar_pole = TrafficLightRadarPole.objects.create(
code="RP_ROM001",
intersection=self.intersection,
geom=geom_intersection,
status="active"
)
# Create the radar asset
self.radar = TrafficLightRadar.objects.create(
code="RAD_PREV_ROM001",
category=self.category,
model=self.radar_model,
intersection=self.intersection,
pole=self.radar_pole,
geom=geom_intersection,
status="active"
)
def test_intersection_is_radar(self):
# Verify fields
self.assertTrue(self.intersection.is_radar)
self.assertEqual(self.radar.intersection, self.intersection)
self.assertEqual(self.intersection.radars.count(), 1)
def test_geojson_contains_is_radar(self):
self.client.force_login(self.user)
response = self.client.get(reverse("assets:trafficlights_intersections_geojson"))
self.assertEqual(response.status_code, 200)
data = response.json()
# Verify intersection
features = data["features"]
radar_features = [f for f in features if f["properties"]["code"] == "ROM001"]
self.assertEqual(len(radar_features), 1)
self.assertTrue(radar_features[0]["properties"]["is_radar"])
def test_detail_view_includes_radars(self):
self.client.force_login(self.user)
response = self.client.get(reverse("assets:trafficlights_intersections_detail", args=[self.intersection.id]))
self.assertEqual(response.status_code, 200)
# Check context
self.assertIn("assets_geojson", response.context)
assets_geojson = response.context["assets_geojson"]
self.assertEqual(assets_geojson["type"], "FeatureCollection")
radar_assets = [f for f in assets_geojson["features"] if f["properties"]["asset_type"] == "trafficlightradar"]
self.assertEqual(len(radar_assets), 1)
self.assertEqual(radar_assets[0]["properties"]["code"], "RAD_PREV_ROM001")
def test_detail_view_includes_radar_poles(self):
self.client.force_login(self.user)
response = self.client.get(reverse("assets:trafficlights_intersections_detail", args=[self.intersection.id]))
self.assertEqual(response.status_code, 200)
# Check assets list in context
self.assertIn("assets", response.context)
assets = response.context["assets"]
self.assertIn("poles", assets)
# Verify the radar pole is in the poles list
self.assertTrue(any(p.code == "RP_ROM001" for p in assets["poles"]))
# Check GeoJSON has the correct asset_type and details
self.assertIn("assets_geojson", response.context)
assets_geojson = response.context["assets_geojson"]
radar_pole_assets = [f for f in assets_geojson["features"] if f["properties"]["asset_type"] == "trafficlightradarpole"]
self.assertEqual(len(radar_pole_assets), 1)
self.assertEqual(radar_pole_assets[0]["properties"]["code"], "RP_ROM001")
def test_detail_view_includes_georeferenced_documents(self):
from django.contrib.contenttypes.models import ContentType
from documents.models import ManagedDocument, DocumentAttachment, DocumentFolder, DocumentFolderAttachment
# Ensure we have a default folder for the intersection to enable managed documents
folder = DocumentFolder.objects.create(name="Intersection Folder", slug="intersection-folder", created_by=self.user)
ct_tli = ContentType.objects.get_for_model(self.intersection.__class__)
DocumentFolderAttachment.objects.create(
folder=folder,
content_type=ct_tli,
object_id=self.intersection.id,
is_default=True
)
# Create a georeferenced document and attach it
doc = ManagedDocument.objects.create(
title="Plan Georef",
georeference_coords=[[4.1, 50.1], [4.2, 50.1], [4.2, 50.2], [4.1, 50.2]],
georeference_page=1,
created_by=self.user
)
doc.folders.add(folder)
DocumentAttachment.objects.create(
document=doc,
content_type=ct_tli,
object_id=self.intersection.id
)
self.client.force_login(self.user)
response = self.client.get(reverse("assets:trafficlights_intersections_detail", args=[self.intersection.id]))
self.assertEqual(response.status_code, 200)
self.assertIn("georeferenced_documents_json", response.context)
import json
georef_docs = json.loads(response.context["georeferenced_documents_json"])
self.assertEqual(len(georef_docs), 1)
self.assertEqual(georef_docs[0]["title"], "Plan Georef")
self.assertEqual(georef_docs[0]["coords"], [[4.1, 50.1], [4.2, 50.1], [4.2, 50.2], [4.1, 50.2]])
def test_unlink_parent_from_child(self):
# 1. Create a parent PublicLightingPole
from assets.models.publiclighting import PublicLightingStreet, PublicLightingPoleModel, PublicLightingPole
pl_street = PublicLightingStreet.objects.create(code="PL-ST-01")
pl_model = PublicLightingPoleModel.objects.create(code="PL-MOD-01")
pl_pole = PublicLightingPole.objects.create(code="PL-POLE-01", model=pl_model, street=pl_street)
# 2. Link parent to our radar child
self.radar.publiclighting_pole = pl_pole
self.radar.save()
# Check it is linked
self.radar.refresh_from_db()
self.assertEqual(self.radar.publiclighting_pole, pl_pole)
# 3. Give edit permission to user for trafficlights thematic
UserThematics.objects.filter(user_config=self.config, thematic=self.thematic).update(can_edit_assets=True)
# 4. Log in the user
self.client.force_login(self.user)
# 5. Call set-parent endpoint to unlink (parent_id=None/null)
response = self.client.post(
reverse("assets:set_parent_asset", kwargs={"asset_model": "trafficlightradar", "asset_id": self.radar.id}),
data=json.dumps({
"field_name": "publiclighting_pole",
"parent_model": "publiclightingpole",
"parent_id": None
}),
content_type="application/json"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
self.assertEqual(data["field_name"], "publiclighting_pole")
# 6. Verify database state is updated (unlinked)
self.radar.refresh_from_db()
self.assertIsNone(self.radar.publiclighting_pole)
def test_radar_position_sync_when_support_pole_moves(self):
# Move the radar pole
from django.contrib.gis.geos import Point
new_geom = Point(648100, 670100, srid=3812)
new_geom_4326 = new_geom.transform(4326, clone=True)
self.radar_pole.geom = new_geom
self.radar_pole.save()
# Check if the child radar position updated automatically
self.radar.refresh_from_db()
self.assertEqual(self.radar.geom, new_geom)
self.assertAlmostEqual(self.radar.lon, new_geom_4326.x, places=5)
self.assertAlmostEqual(self.radar.lat, new_geom_4326.y, places=5)
self.assertEqual(self.radar.geojson, new_geom_4326.geojson)
class CategoryAllowedModelTest(TestCase):
def setUp(self):
# Create user & config
self.user = User.objects.create_user(username="testuser_allowed", password="password123")
self.config = UserConfig.objects.create(user=self.user, limit_assets_to_contracts=False)
# Create thematic
self.thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={"name_fr": "Feux", "name_nl": "Verkeerslichten"}
)
# Give access
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True
)
# Get/create category
self.category, _ = AssetCategory.objects.get_or_create(
code="TL_RADAR_PREVENTIF",
defaults={
"name_fr": "Radar Préventif",
"name_nl": "Preventieve Radar",
"thematic": self.thematic
}
)
# Associate content types to category
from django.contrib.contenttypes.models import ContentType
from assets.models.trafficlights import TrafficLightRadar, TrafficLightAccessory
self.ct_radar = ContentType.objects.get_for_model(TrafficLightRadar)
self.ct_accessory = ContentType.objects.get_for_model(TrafficLightAccessory)
self.category.allowed_models.add(self.ct_radar)
self.category.allowed_models.add(self.ct_accessory)
def test_get_categories_allowed_models(self):
self.client.force_login(self.user)
response = self.client.get(reverse("assets:get_asset_categories", args=[self.thematic.code]))
self.assertEqual(response.status_code, 200)
data = response.json()
# Verify the radar category returns allowed models
categories = data.get("categories", [])
radar_cat = next((c for c in categories if c["id"] == self.category.id), None)
self.assertIsNotNone(radar_cat)
allowed_models = radar_cat.get("allowed_models", [])
self.assertEqual(len(allowed_models), 2)
radar_model = next(m for m in allowed_models if m["model_name"] == "trafficlightradar")
accessory_model = next(m for m in allowed_models if m["model_name"] == "trafficlightaccessory")
# TrafficLightRadar requires a model template
self.assertTrue(radar_model["is_model_required"])
# TrafficLightAccessory does not require a model template
self.assertFalse(accessory_model["is_model_required"])
class StructuresLocationDetailAddAssetModalTest(TestCase):
def setUp(self):
from common.models import Role
from assets.models.structures import Structure, StructureLocation
from django.contrib.gis.geos import Point
self.user = User.objects.create_superuser(username="admin_struct", password="password123")
self.config = UserConfig.objects.create(user=self.user, is_intern=True)
admin_role, _ = Role.objects.get_or_create(name="admin")
self.config.roles.add(admin_role)
self.thematic, _ = Thematic.objects.get_or_create(
code="structures",
defaults={"name_fr": "Ouvrages d'art", "name_nl": "Kunstwerken"}
)
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True
)
self.category, _ = AssetCategory.objects.get_or_create(
code="TU_BAL_TEST",
defaults={
"name_fr": "Balisage Test",
"name_nl": "Balisage Test NL",
"thematic": self.thematic
}
)
from django.contrib.gis.geos import MultiPolygon, Polygon
poly = Polygon(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0)))
mpoly = MultiPolygon(poly, srid=3812)
self.structure = Structure.objects.create(
code="OA_TEST_01",
name_fr="Pont Test",
structure_type="bridge"
)
self.location = StructureLocation.objects.create(
code="LOC_TEST_01",
name_fr="Localisation Test",
structure=self.structure,
geom=mpoly
)
def test_structures_location_detail_contains_categories(self):
self.client.force_login(self.user)
response = self.client.get(reverse("assets:structures_locations_detail", args=[self.location.id]))
self.assertEqual(response.status_code, 200)
self.assertIn("categories", response.context)
categories_in_context = response.context["categories"]
self.assertTrue(any(c.id == self.category.id for c in categories_in_context))
self.assertContains(response, "Balisage Test")
self.assertContains(response, f'name="location_id" value="{self.location.id}"')
self.assertIn("suggested_code", response.context)
self.assertEqual(response.context["suggested_code"], "LOC_TEST_01-000001")
self.assertContains(response, 'value="LOC_TEST_01-000001"')
def test_incremental_code_generation_with_existing_assets(self):
from assets.models.structures import StructureGeoAsset, StructureAssetModel
from assets.utils.asset_code_generator import get_next_structure_asset_code, get_suggested_asset_code
model = StructureAssetModel.objects.create(code="MOD_TEST", name_fr="Modèle Test", category=self.category)
StructureGeoAsset.objects.create(
code="LOC_TEST_01-000023",
name_fr="Capteur 23",
structure=self.structure,
location=self.location,
model=model
)
StructureGeoAsset.objects.create(
code="LOC_TEST_01-000024",
name_fr="Capteur 24",
structure=self.structure,
location=self.location,
model=model
)
next_code = get_next_structure_asset_code(location=self.location)
self.assertEqual(next_code, "LOC_TEST_01-000025")
suggested = get_suggested_asset_code("structures", location_id=self.location.id)
self.assertEqual(suggested, "LOC_TEST_01-000025")
# Test API endpoint
self.client.force_login(self.user)
api_res = self.client.get(reverse("assets:get_suggested_asset_code_api"), {
"thematic_code": "structures",
"location_id": self.location.id
})
self.assertEqual(api_res.status_code, 200)
data = api_res.json()
self.assertTrue(data.get("success"))
self.assertEqual(data.get("suggested_code"), "LOC_TEST_01-000025")
# Test page rendering has updated suggested code
page_res = self.client.get(reverse("assets:structures_locations_detail", args=[self.location.id]))
self.assertEqual(page_res.status_code, 200)
self.assertEqual(page_res.context["suggested_code"], "LOC_TEST_01-000025")
self.assertContains(page_res, 'value="LOC_TEST_01-000025"')
# Test intervention code suggestion (mobile / desktop intervention)
from interventions.models import Intervention, InterventionLocation
from django.contrib.contenttypes.models import ContentType
itv = Intervention.objects.create(
code="ITV_TEST_99",
title="Intervention Test Structures",
thematic=self.thematic
)
loc_ct = ContentType.objects.get_for_model(self.location)
InterventionLocation.objects.create(
intervention=itv,
content_type=loc_ct,
object_id=self.location.id
)
itv_suggested = get_suggested_asset_code("structures", intervention_id=itv.id)
self.assertEqual(itv_suggested, "LOC_TEST_01-000025")
itv_api_res = self.client.get(reverse("assets:get_suggested_asset_code_api"), {
"thematic_code": "structures",
"intervention_id": itv.id
})
self.assertEqual(itv_api_res.status_code, 200)
self.assertEqual(itv_api_res.json().get("suggested_code"), "LOC_TEST_01-000025")
def test_code_suggestion_specific_location_avoids_other_sublocation_dominant_prefix(self):
"""
Verify that when generating a code for location WTB23B, it generates WTB23B-000001 (or next WTB23B)
even if another location WTB23J under the same structure has many existing assets.
"""
from assets.models.structures import Structure, StructureLocation, StructureGeoAsset, StructureAssetModel
from assets.utils.asset_code_generator import get_next_structure_asset_code, get_suggested_asset_code
from interventions.models import Intervention, InterventionLocation
from django.contrib.contenttypes.models import ContentType
struct_belliard = Structure.objects.create(code="WTB23", name_fr="Tunnel Belliard")
loc_b = StructureLocation.objects.create(code="WTB23B", name_fr="Belliard B", structure=struct_belliard)
loc_j = StructureLocation.objects.create(code="WTB23J", name_fr="Belliard J", structure=struct_belliard)
model = StructureAssetModel.objects.create(code="MOD_BELL", name_fr="Modèle Belliard", category=self.category)
# Create 10 assets for WTB23J
for i in range(1, 11):
StructureGeoAsset.objects.create(
code=f"WTB23J-{i:06d}",
name_fr=f"Asset J {i}",
structure=struct_belliard,
location=loc_j,
model=model
)
# For location WTB23B with 0 existing assets, it should suggest WTB23B-000001, NOT WTB23J-000011!
suggested_b = get_next_structure_asset_code(location=loc_b)
self.assertEqual(suggested_b, "WTB23B-000001")
# For an intervention linked to WTB23B
itv = Intervention.objects.create(code="ITV_WTB23B", title="Cyclique Belliard", thematic=self.thematic)
loc_ct = ContentType.objects.get_for_model(loc_b)
InterventionLocation.objects.create(intervention=itv, content_type=loc_ct, object_id=loc_b.id)
itv_code = get_suggested_asset_code("structures", intervention_id=itv.id)
self.assertEqual(itv_code, "WTB23B-000001")
# If WTB23B already has an asset WTB23B-000001, it should suggest WTB23B-000002
StructureGeoAsset.objects.create(
code="WTB23B-000001",
name_fr="Asset B 1",
structure=struct_belliard,
location=loc_b,
model=model
)
itv_code_2 = get_suggested_asset_code("structures", intervention_id=itv.id)
self.assertEqual(itv_code_2, "WTB23B-000002")
def test_create_asset_api_success_with_model_and_intervention(self):
"""
Verify create_asset_api creates a StructureGeoAsset with model, resolves location and links to intervention.
"""
from assets.models.structures import Structure, StructureLocation, StructureGeoAsset, StructureAssetModel
from interventions.models import Intervention, InterventionLocation, InterventionAsset
from django.contrib.contenttypes.models import ContentType
struct = Structure.objects.create(code="WTB23_TEST", name_fr="Tunnel Belliard Test")
loc = StructureLocation.objects.create(code="WTB23B_TEST", name_fr="Belliard B Test", structure=struct)
model = StructureAssetModel.objects.create(code="MOD_API_TEST", name_fr="Modèle API", category=self.category)
# Allow model for category
ct = ContentType.objects.get_for_model(StructureGeoAsset)
self.category.allowed_models.add(ct)
itv = Intervention.objects.create(
code="ITV_CREATE_ASSET",
title="Intervention Création",
thematic=self.thematic,
maintain_type="preventive"
)
loc_ct = ContentType.objects.get_for_model(loc)
InterventionLocation.objects.create(intervention=itv, content_type=loc_ct, object_id=loc.id)
self.client.force_login(self.user)
payload = {
"thematic_code": "structures",
"category_id": self.category.id,
"model_id": model.id,
"code": "WTB23B_TEST-000001",
"name_fr": "Nouvel Équipement Test",
"intervention_id": itv.id
}
res = self.client.post(
reverse("assets:create_asset_api"),
data=json.dumps(payload),
content_type="application/json"
)
self.assertEqual(res.status_code, 200, f"Error: {res.content}")
data = res.json()
self.assertTrue(data.get("success"))
self.assertTrue(data.get("intervention_linked"))
self.assertTrue(data.get("needs_template_selection"))
# Verify asset created in DB
created_asset = StructureGeoAsset.objects.get(code="WTB23B_TEST-000001")
self.assertEqual(created_asset.model, model)
self.assertEqual(created_asset.category, self.category)
self.assertEqual(created_asset.location, loc)
self.assertEqual(created_asset.structure, struct)
self.assertEqual(created_asset.name_fr, "Nouvel Équipement Test")
# Verify linked to intervention
self.assertTrue(InterventionAsset.objects.filter(intervention=itv, object_id=created_asset.id).exists())
from assets.views.trafficlights import parse_dxf_poles
@tag('slow', 'dxf')
@skipUnless(RUN_DXF_TESTS, "Skip slow DXF tests by default")
class TrafficLightDXFImportTest(TestCase):
def setUp(self):
# Create a thematic for traffic lights
self.thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={
"name_fr": "Feux de signalisation",
"name_nl": "Verkeerslichten"
}
)
self.user = User.objects.create_user(username="testuser", password="password123")
self.config = UserConfig.objects.create(user=self.user, limit_assets_to_contracts=False)
# Give thematic access to user
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True
)
# Create a spatial point for the intersection
from django.contrib.gis.geos import Point
geom_intersection = Point(648000, 670000, srid=3812)
# Create intersection with code 'SB0239' matching the DXF file name
self.intersection = TrafficLightIntersection.objects.create(
code="SB0239",
name_fr="Carrefour SB0239",
geom=geom_intersection,
lon=4.3508,
lat=50.8465,
status="active"
)
# Create category for TrafficLightPole
self.category, _ = AssetCategory.objects.get_or_create(
code="TL_POLE",
defaults={
"name_fr": "Poteau de signalisation",
"name_nl": "Paal",
"thematic": self.thematic
}
)
# Create a pole model
from assets.models import TrafficLightPoleModel
self.pole_model = TrafficLightPoleModel.objects.create(
code="POM-01",
name_fr="Poteau Standard",
category=self.category
)
# Create the existing active pole asset that Choice A / Choice B should target
from assets.models import TrafficLightPole
self.existing_pole = TrafficLightPole.objects.create(
code="SB0239_T3", # matches label T3 in DXF
category=self.category,
model=self.pole_model,
intersection=self.intersection,
geom=geom_intersection,
lon=4.3508,
lat=50.8465,
status="active"
)
def test_parse_dxf_poles_real_file(self):
# Verify parse_dxf_poles works on the actual DXF
import os
from django.conf import settings
external_files_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
file_path = os.path.join(external_files_dir, 'SB0239_2026-02-01.dxf')
if os.path.exists(file_path):
poles = parse_dxf_poles(file_path)
self.assertTrue(len(poles) > 0)
# Ensure coordinates and labels are populated
for pole in poles:
self.assertIn('x_31370', pole)
self.assertIn('y_31370', pole)
self.assertIn('lon', pole)
self.assertIn('lat', pole)
self.assertIn('label', pole)
def test_import_dxf_views_flow(self):
import os
from django.conf import settings
external_files_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
file_path = os.path.join(external_files_dir, 'SB0239_2026-02-01.dxf')
if not os.path.exists(file_path):
self.skipTest("Real DXF file for test not found")
self.client.force_login(self.user)
# 1. GET: Select page
url_import = reverse("assets:import_dxf", args=[self.intersection.id])
response = self.client.get(url_import)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "SB0239_2026-02-01.dxf")
# 2. POST: Select file redirects to GET page with selected dxf_file
response = self.client.post(url_import, {"dxf_file": "SB0239_2026-02-01.dxf"})
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, f"{url_import}?dxf_file=SB0239_2026-02-01.dxf")
# 3. POST: Add staged poles via AJAX
url_add = reverse("assets:add_staged_pole", args=[self.intersection.id])
# Add pole 1 (replaces existing by code)
response = self.client.post(url_add, {
"code": "SB0239_T3",
"model_id": str(self.pole_model.id),
"lon": "4.35081",
"lat": "50.84651",
"dxf_file": "SB0239_2026-02-01.dxf"
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
pole_1_id = data["pole"]["id"]
# Add pole 2 (new pole)
response = self.client.post(url_add, {
"code": "SB0239_P02",
"model_id": str(self.pole_model.id),
"lon": "4.35085",
"lat": "50.84655",
"dxf_file": "SB0239_2026-02-01.dxf"
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
pole_2_id = data["pole"]["id"]
# Verify staged assets are created in 'to_be_approved' and 'to_be_validated'
from assets.models import TrafficLightPole
staged_poles = TrafficLightPole.objects.filter(intersection=self.intersection, status="to_be_approved")
self.assertEqual(staged_poles.count(), 2)
self.assertTrue(staged_poles.filter(code="SB0239_T3").exists())
self.assertTrue(staged_poles.filter(code="SB0239_P02").exists())
for sp in staged_poles:
self.assertEqual(sp.validation_status, "to_be_validated")
self.assertEqual(sp.note, "Créé manuellement sur plan DXF : SB0239_2026-02-01.dxf")
# Test deleting pole 2 via AJAX (verifying delete view)
url_delete = reverse("assets:delete_staged_pole", args=[self.intersection.id, pole_2_id])
response = self.client.post(url_delete)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["success"])
self.assertEqual(TrafficLightPole.objects.filter(intersection=self.intersection, status="to_be_approved").count(), 1)
# Re-add pole 2
response = self.client.post(url_add, {
"code": "SB0239_P02",
"model_id": str(self.pole_model.id),
"lon": "4.35085",
"lat": "50.84655",
"dxf_file": "SB0239_2026-02-01.dxf"
})
self.assertEqual(response.status_code, 200)
# 4. POST: Integrate Choice A (replace by code)
url_integrate = reverse("assets:integrate_dxf", args=[self.intersection.id])
response = self.client.post(url_integrate, {"choice": "choice_a"})
self.assertEqual(response.status_code, 302)
# Staged assets should now be active/validated
self.assertEqual(TrafficLightPole.objects.filter(intersection=self.intersection, status="to_be_approved").count(), 0)
active_poles = TrafficLightPole.objects.filter(intersection=self.intersection, status="active")
self.assertEqual(active_poles.count(), 2)
self.assertTrue(active_poles.filter(code="SB0239_T3").exists())
self.assertTrue(active_poles.filter(code="SB0239_P02").exists())
# The old active pole should have been archived/removed and its code changed/uninstallation date set
removed_pole = TrafficLightPole.objects.filter(intersection=self.intersection, status="removed").first()
self.assertIsNotNone(removed_pole)
self.assertIn("SB0239_T3", removed_pole.code)
self.assertIsNotNone(removed_pole.uninstallation_date)
def test_integrate_choice_b_full_replacement(self):
self.client.force_login(self.user)
# Stage two poles via AJAX
url_add = reverse("assets:add_staged_pole", args=[self.intersection.id])
self.client.post(url_add, {
"code": "SB0239_T3",
"model_id": str(self.pole_model.id),
"lon": "4.35081",
"lat": "50.84651",
"dxf_file": "SB0239_2026-02-01.dxf"
})
self.client.post(url_add, {
"code": "SB0239_P02",
"model_id": str(self.pole_model.id),
"lon": "4.35085",
"lat": "50.84655",
"dxf_file": "SB0239_2026-02-01.dxf"
})
# POST: Integrate Choice B (replace all)
url_integrate = reverse("assets:integrate_dxf", args=[self.intersection.id])
response = self.client.post(url_integrate, {"choice": "choice_b"})
self.assertEqual(response.status_code, 302)
# Staged assets should now be active
from assets.models import TrafficLightPole
self.assertEqual(TrafficLightPole.objects.filter(intersection=self.intersection, status="to_be_approved").count(), 0)
# All old active poles should have been removed
removed_poles = TrafficLightPole.objects.filter(intersection=self.intersection, status="removed")
self.assertEqual(removed_poles.count(), 1)
self.assertEqual(removed_poles.first().id, self.existing_pole.id)
# Staged poles should be active
active_poles = TrafficLightPole.objects.filter(intersection=self.intersection, status="active")
self.assertEqual(active_poles.count(), 2)
def test_save_dxf_layers(self):
import os
from django.conf import settings
external_files_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
file_path = os.path.join(external_files_dir, 'SB0239_2026-02-01.dxf')
if not os.path.exists(file_path):
self.skipTest("Real DXF file for test not found")
self.client.force_login(self.user)
# Trigger layout GeoJSON generation and caching
url_geojson = reverse("assets:dxf_layout_geojson", args=[self.intersection.id])
self.client.get(f"{url_geojson}?file=SB0239_2026-02-01.dxf")
# Save a subset of layers (e.g. only 'IGN_Signalisation_Feu_Poteau')
url_save = reverse("assets:save_dxf_layers", args=[self.intersection.id])
response = self.client.post(url_save, {
"dxf_file": "SB0239_2026-02-01.dxf",
"layers": "IGN_Signalisation_Feu_Poteau"
})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["success"])
# Querying the dxf-layout-geojson endpoint should now return ONLY features from the saved layer
url_geojson = reverse("assets:dxf_layout_geojson", args=[self.intersection.id])
response_geojson = self.client.get(f"{url_geojson}?file=SB0239_2026-02-01.dxf")
self.assertEqual(response_geojson.status_code, 200)
data = response_geojson.json()
self.assertEqual(data["selected_layers"], ["IGN_Signalisation_Feu_Poteau"])
for feature in data["features"]:
self.assertEqual(feature["properties"]["Layer"], "IGN_Signalisation_Feu_Poteau")
# Verify that the overlay ManagedDocument was created and georeferenced
from documents.models import ManagedDocument
from django.contrib.contenttypes.models import ContentType
from assets.models import TrafficLightIntersectionDocument
if getattr(TrafficLightIntersectionDocument, "DOCUMENT_MANAGEMENT_SYNC", False):
from documents.models import DocumentAttachment
ct_tli = ContentType.objects.get_for_model(self.intersection)
dxf_attachment = DocumentAttachment.objects.filter(
content_type=ct_tli,
object_id=self.intersection.id,
document__versions__file__contains="SB0239_2026-02-01"
).first()
self.assertIsNotNone(dxf_attachment)
dxf_source_id = dxf_attachment.document.id
else:
from assets.models import TrafficLightIntersectionDocument
doc_obj = TrafficLightIntersectionDocument.objects.filter(
intersection=self.intersection,
file__contains="SB0239_2026-02-01"
).first()
self.assertIsNotNone(doc_obj)
self.assertIsNotNone(doc_obj.managed_document)
dxf_source_id = doc_obj.managed_document.id
overlay_doc = ManagedDocument.objects.filter(
unique_params__dxf_source_id=dxf_source_id,
is_archived=False
).first()
self.assertIsNotNone(overlay_doc)
self.assertIsNotNone(overlay_doc.georeference_coords)
# Verify that the generated overlay file has the correct unsuffixed name and exists on disk
import os
from django.conf import settings
overlay_filename = "sb0239_2026-02-01.overlay.png"
overlay_path = os.path.join(settings.MEDIA_ROOT, 'dxf_layouts', overlay_filename)
self.assertTrue(os.path.exists(overlay_path))
def test_save_dxf_layers_managed_document(self):
import os
from django.conf import settings
from django.core.files.base import ContentFile
from documents.models import ManagedDocument, DocumentVersion, DocumentAttachment
from django.contrib.contenttypes.models import ContentType
external_files_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
dxf_path = os.path.join(external_files_dir, 'SB0239_2026-02-01.dxf')
if not os.path.exists(dxf_path):
self.skipTest("Real DXF file for test not found")
self.client.force_login(self.user)
# Create a ManagedDocument and version mimicking direct upload via Document Management
managed_doc = ManagedDocument.objects.create(
title="Direct Upload",
created_by=self.user
)
with open(dxf_path, 'rb') as f:
dxf_content = ContentFile(f.read(), name="SB0239_2026-02-01_direct.dxf")
version = DocumentVersion.objects.create(
document=managed_doc,
file=dxf_content,
uploaded_by=self.user,
status=DocumentVersion.STATUS_APPROVED
)
# Attach it to the intersection
ct = ContentType.objects.get_for_model(self.intersection)
DocumentAttachment.objects.create(
document=managed_doc,
content_type=ct,
object_id=self.intersection.id,
attached_by=self.user
)
# Trigger saving layers on this managed document using the filename of the version
dxf_filename = os.path.basename(version.file.name)
url_save = reverse("assets:save_dxf_layers", args=[self.intersection.id])
response = self.client.post(url_save, {
"dxf_file": dxf_filename,
"layers": "IGN_Signalisation_Feu_Poteau"
})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["success"])
def test_dxf_document_deletion(self):
import os
from django.conf import settings
from documents.models import ManagedDocument, DocumentVersion
from assets.models import TrafficLightIntersectionDocument, TrafficLightPole
self.user.is_superuser = True
self.user.save()
self.client.force_login(self.user)
# Create a mock DXF document
doc = TrafficLightIntersectionDocument.objects.create(
intersection=self.intersection,
file="uploaded_test.dxf",
document_type='other',
uploaded_by=self.user
)
# Create the ManagedDocument and current version associated
m_doc = ManagedDocument.objects.create(
title="uploaded_test.dxf",
created_by=self.user
)
DocumentVersion.objects.create(
document=m_doc,
file="uploaded_test.dxf",
version_number=1,
uploaded_by=self.user
)
# Link them
doc.managed_document = m_doc
doc.save()
# Create a mock overlay document
overlay_doc = ManagedDocument.objects.create(
title="uploaded_test.overlay.png",
created_by=self.user,
unique_params={"format": "overlay_png", "dxf_source_id": m_doc.id}
)
# Create a mock imported pole with the dxf filename in note
pole = TrafficLightPole.objects.create(
intersection=self.intersection,
model=self.pole_model,
code="TEST_POLE_DEL",
geom="POINT(0 0)",
status="active",
note="Importé depuis le plan DXF : uploaded_test.dxf",
updated_by=self.user
)
# Delete document via client DocumentDeleteView
url_delete = reverse("documents:delete", args=[m_doc.pk])
# Confirm delete request with checkboxes checked
response = self.client.post(url_delete, {
"delete_overlay": "on",
"delete_vectors": "on"
})
self.assertEqual(response.status_code, 302)
# The document, overlay document, legacy intersection doc, and imported pole should be deleted
self.assertFalse(ManagedDocument.objects.filter(pk=m_doc.pk).exists())
self.assertFalse(ManagedDocument.objects.filter(pk=overlay_doc.pk).exists())
self.assertFalse(TrafficLightIntersectionDocument.objects.filter(pk=doc.pk).exists())
self.assertFalse(TrafficLightPole.objects.filter(pk=pole.pk).exists())
def test_import_dxf_via_upload(self):
self.client.force_login(self.user)
import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
# Read the real DXF file
read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
real_dxf_path = os.path.join(read_only_dxf_dir, 'SB0239_2026-02-01.dxf')
if not os.path.exists(real_dxf_path):
self.skipTest("Real DXF file for test not found")
with open(real_dxf_path, 'rb') as f:
dxf_content = f.read()
# Create SimpleUploadedFile
uploaded_file = SimpleUploadedFile("uploaded_test.dxf", dxf_content, content_type="application/dxf")
url_import = reverse("assets:import_dxf", args=[self.intersection.id])
response = self.client.post(url_import, {"dxf_file_upload": uploaded_file})
self.assertEqual(response.status_code, 302)
# Extract temp filename from redirect URL
redirect_url = response.url
self.assertIn("?dxf_file=temp_", redirect_url)
temp_filename = redirect_url.split("?dxf_file=")[1]
# The file should exist in the dxf_temp directory
temp_file_path = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_temp', temp_filename)
self.assertTrue(os.path.exists(temp_file_path))
# Now simulate Step 2: POST to save_dxf_layers
url_save = reverse("assets:save_dxf_layers", args=[self.intersection.id])
response_save = self.client.post(url_save, {
"dxf_file": temp_filename,
"layers": "IGN_Signalisation_Feu_Poteau"
})
self.assertEqual(response_save.status_code, 200)
self.assertTrue(response_save.json()["success"])
# Verify the document was saved to the database and file exists
from assets.models import TrafficLightIntersectionDocument
if getattr(TrafficLightIntersectionDocument, "DOCUMENT_MANAGEMENT_SYNC", False):
from django.contrib.contenttypes.models import ContentType
from documents.models import ManagedDocument
ct_tli = ContentType.objects.get_for_model(self.intersection)
m_doc = ManagedDocument.objects.filter(
attachments__content_type=ct_tli,
attachments__object_id=self.intersection.id
).first()
self.assertIsNotNone(m_doc)
latest_v = m_doc.latest_version
self.assertIsNotNone(latest_v)
self.assertTrue(os.path.exists(latest_v.file.path))
dxf_filename = os.path.basename(latest_v.file.name)
dxf_file_path = latest_v.file.path
else:
from assets.models import TrafficLightIntersectionDocument
doc = TrafficLightIntersectionDocument.objects.filter(intersection=self.intersection).first()
self.assertIsNotNone(doc)
self.assertTrue(os.path.exists(doc.file.path))
dxf_filename = os.path.basename(doc.file.name)
dxf_file_path = doc.file.path
# Check overlay image and layout geojson existence
overlay_name = dxf_filename.lower().replace('.dxf', '.overlay.png')
overlay_path = os.path.join(settings.MEDIA_ROOT, 'dxf_layouts', overlay_name)
self.assertTrue(os.path.exists(overlay_path))
geojson_name = dxf_filename.lower().replace('.dxf', '.layout.geojson')
geojson_path = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_layouts', geojson_name)
self.assertTrue(os.path.exists(geojson_path))
# Cleanup saved file after test
try:
os.remove(dxf_file_path)
except Exception:
pass
try:
os.remove(temp_file_path)
except Exception:
pass
@tag('slow', 'dxf')
@skipUnless(RUN_DXF_TESTS, "Skip slow DXF tests by default")
class TrafficLightCrossPlanImportTest(TestCase):
def setUp(self):
from assets.models import (
TrafficLightPoleModel, TrafficLightLanternModel,
TrafficLightDetectorModel, TrafficLightCableModel
)
# Create a thematic for traffic lights
self.thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={
"name_fr": "Feux de signalisation",
"name_nl": "Verkeerslichten"
}
)
# Create a user
self.user = User.objects.create_user(username="testuser", password="password123")
self.config = UserConfig.objects.create(user=self.user)
# Give view/edit permissions to user
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True
)
# Create intersection SWB01 in DB
self.intersection = TrafficLightIntersection.objects.create(
code="SWB01",
status="active"
)
# Setup basic models so fallback works
TrafficLightPoleModel.objects.get_or_create(
code="poteau_droit",
defaults={"name_fr": "Poteau droit, gris 3.1m", "name_nl": "Poteau droit, gris 3.1m"}
)
TrafficLightLanternModel.objects.get_or_create(
code="3v200",
defaults={"name_fr": "3V200", "name_nl": "3V200", "voltage": 230}
)
TrafficLightDetectorModel.objects.get_or_create(
code="det_model",
defaults={"name_fr": "Drukknop", "name_nl": "Drukknop"}
)
TrafficLightCableModel.objects.get_or_create(
code="cab_model",
defaults={"name_fr": "SVAVB 61x1,5mm²", "name_nl": "SVAVB 61x1,5mm²", "number_of_strands": 61}
)
def test_import_cross_plan_get(self):
self.client.force_login(self.user)
url = reverse("assets:import_cross_plan", args=[self.intersection.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "assets/trafficlights_import_cross_plan.html")
self.assertEqual(response.context["step"], "select")
def test_import_cross_plan_post_and_integrate(self):
self.client.force_login(self.user)
import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from assets.models.trafficlights import (
TrafficLightPole, TrafficLightCable, TrafficLightLantern, TrafficLightDetector
)
# Path to real Excel file
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")
# 1. Test POST import view (Analysis & Comparison Preview)
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.assertEqual(response.context["step"], "preview")
self.assertTrue(len(response.context["poles"]) > 0)
self.assertTrue(len(response.context["lanterns"]) > 0)
# 2. Test POST integrate view (Global replace all action)
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"],
"global_action": "replace_all"
})
self.assertEqual(response_integrate.status_code, 302) # Redirects back to detail page
# Verify created assets in DB
self.assertTrue(TrafficLightPole.objects.filter(intersection=self.intersection, status="active").count() > 0)
self.assertTrue(TrafficLightCable.objects.filter(intersection=self.intersection, status="active").count() > 0)
self.assertTrue(TrafficLightLantern.objects.filter(pole__intersection=self.intersection, status="active").count() > 0)
self.assertTrue(TrafficLightDetector.objects.filter(intersection=self.intersection, status="active").count() > 0)
def test_prefix_match_and_rename_lantern_unchecked(self):
from assets.models import TrafficLightPole, TrafficLightLantern, TrafficLightPoleModel, TrafficLightLanternModel, AssetCategory
pole_model = TrafficLightPoleModel.objects.first()
lantern_model = TrafficLightLanternModel.objects.first()
category_pole = AssetCategory.objects.filter(code="TL_POLE").first()
category_lantern = AssetCategory.objects.filter(code="TL_LANTERN").first() or AssetCategory.objects.filter(code="TL_LANTERNE").first()
# Pre-create pole in DB
pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_A01",
model=pole_model,
category=category_pole,
status="active"
)
# Pre-create lantern with old nomenclature (no phase suffix)
old_lantern = TrafficLightLantern.objects.create(
pole=pole,
code="SWB01_A01_LAN01",
model=lantern_model,
category=category_lantern,
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")
# POST to import_cross_plan to analyze
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)
# 1. Post integrate WITHOUT checking the selection checkbox
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"],
"action_lantern_SWB01_A01_LAN01_T1": "replace",
"db_id_lantern_SWB01_A01_LAN01_T1": str(old_lantern.id)
# Not passing selected_lantern_SWB01_A01_LAN01_T1
})
self.assertEqual(response_integrate.status_code, 302)
# Old lantern must still be active (skipped)
old_lantern.refresh_from_db()
self.assertEqual(old_lantern.status, "active")
def test_prefix_match_and_rename_lantern_checked(self):
from assets.models import TrafficLightPole, TrafficLightLantern, TrafficLightPoleModel, TrafficLightLanternModel, AssetCategory
pole_model = TrafficLightPoleModel.objects.first()
lantern_model = TrafficLightLanternModel.objects.first()
category_pole = AssetCategory.objects.filter(code="TL_POLE").first()
category_lantern = AssetCategory.objects.filter(code="TL_LANTERN").first() or AssetCategory.objects.filter(code="TL_LANTERNE").first()
# Pre-create pole in DB
pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_A01",
model=pole_model,
category=category_pole,
status="active"
)
# Pre-create lantern with old nomenclature (no phase suffix)
old_lantern = TrafficLightLantern.objects.create(
pole=pole,
code="SWB01_A01_LAN01",
model=lantern_model,
category=category_lantern,
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")
# POST to import_cross_plan to analyze
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)
# 2. Post integrate WITH checking the selection checkbox
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": "replace",
"db_id_lantern_SWB01_A01_LAN01_T1": str(old_lantern.id)
})
self.assertEqual(response_integrate.status_code, 302)
# Old lantern must now be archived
old_lantern.refresh_from_db()
self.assertEqual(old_lantern.status, "archived")
# A new replacement lantern must be active with the updated code
new_active = TrafficLightLantern.objects.filter(pole=pole, code="SWB01_A01_LAN01_T1", status="active").first()
self.assertIsNotNone(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, TrafficLightPoleModel, TrafficLightLanternModel
pole_model = TrafficLightPoleModel.objects.first()
lantern_model = TrafficLightLanternModel.objects.first()
pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_A01",
model=pole_model,
status="active"
)
old_lantern = TrafficLightLantern.objects.create(
pole=pole,
code="SWB01_A01_LAN01",
model=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):
from assets.models import (
TrafficLightPole, TrafficLightController, TrafficLightControllerHardware,
TrafficLightAccessory, TrafficLightPoleModel, TrafficLightControllerModel
)
from assets.models.sign import SignPanel, SignPanelModel
from assets.models.its import ITSGeoAsset, ITSAssetModel
pole_model = TrafficLightPoleModel.objects.first()
ctrl_model, _ = TrafficLightControllerModel.objects.get_or_create(code="ctrl_mod", defaults={"name_fr": "Model Ctrl"})
sign_model, _ = SignPanelModel.objects.get_or_create(code="sp_mod", defaults={"name_fr": "Model SP"})
its_model, _ = ITSAssetModel.objects.get_or_create(code="its_mod", defaults={"name_fr": "Model ITS"})
pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_A01",
model=pole_model,
status="active"
)
ctrl = TrafficLightController.objects.create(
intersection=self.intersection,
code="SWB01_C01",
model=ctrl_model,
status="active"
)
hw = TrafficLightControllerHardware.objects.create(
controller=ctrl,
code="SWB01_HW_1",
status="active"
)
sign = SignPanel.objects.create(
trafficlight_pole=pole,
code="SWB01_SP01",
model=sign_model,
status="active"
)
its = ITSGeoAsset.objects.create(
trafficlights_pole=pole,
code="SWB01_ITS01",
model=its_model,
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
from assets.utils.cross_plan_parser import parse_cross_plan
# Create a mock cross plan workbook
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "kruisjesplan"
# Row 1: Sleutel
ws.cell(row=1, column=1, value="Sleutel:")
ws.cell(row=1, column=2, value="SWB01")
# Cable 2 section start
ws.cell(row=5, column=1, value="Câble 02 TWAVB 10x2x0,8 mm²")
# Row 6: Richting and Kringen headers
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
# Row 7: Pole headers
ws.cell(row=7, column=5, value="B01")
ws.cell(row=7, column=6, value="A01")
# Rows for telecom detectors
# Row 8: RC
ws.cell(row=8, column=1, value="com")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="RC")
ws.cell(row=8, column=4, value="")
ws.cell(row=8, column=6, value="X")
# Row 9: RD
ws.cell(row=9, column=1, value="com")
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="RD")
ws.cell(row=9, column=4, value="")
ws.cell(row=9, column=6, value="X")
# Row 10: Rm T2,B1,B2
ws.cell(row=10, column=1, value="com")
ws.cell(row=10, column=2, value="3")
ws.cell(row=10, column=3, value="Rm T2,B1,B2")
ws.cell(row=10, column=4, value="")
ws.cell(row=10, column=5, value="X")
# Save workbook to temp file
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
# Check parsed detectors
detectors = deduced["detectors"]
self.assertTrue(len(detectors) >= 3)
# Verify RC
rc_det = next((d for d in detectors if d["code"] == "SWB01_A01_RC"), None)
self.assertIsNotNone(rc_det)
self.assertEqual(rc_det["model_name"], "TM60")
self.assertEqual(rc_det["pole_code"], "SWB01_A01")
# Verify RD
rd_det = next((d for d in detectors if d["code"] == "SWB01_A01_RD"), None)
self.assertIsNotNone(rd_det)
self.assertEqual(rd_det["model_name"], "TM60")
self.assertEqual(rd_det["pole_code"], "SWB01_A01")
# Verify Rm T2
rm_det = next((d for d in detectors if d["code"] == "SWB01_B01_RMT2"), None)
self.assertIsNotNone(rm_det)
self.assertEqual(rm_det["model_name"], "TM60")
self.assertEqual(rm_det["pole_code"], "SWB01_B01")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_detector_prefix_match_and_rename(self):
from assets.models import TrafficLightPole, TrafficLightDetector, TrafficLightPoleModel, TrafficLightDetectorModel, AssetCategory
pole_model = TrafficLightPoleModel.objects.first()
detector_model = TrafficLightDetectorModel.objects.first()
category_pole = AssetCategory.objects.filter(code="TL_POLE").first()
category_detector = AssetCategory.objects.filter(code="TL_DETECTOR").first() or AssetCategory.objects.filter(code="TL_DETECTEUR").first()
# Pre-create pole in DB
pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_B01",
model=pole_model,
category=category_pole,
status="active"
)
# Pre-create detector with old nomenclature (no pole suffix)
old_detector = TrafficLightDetector.objects.create(
intersection=self.intersection,
pole=pole,
code="SWB01-RA",
model=detector_model,
category=category_detector,
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")
# POST to import_cross_plan to analyze
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)
# Check detectors in preview context
preview_detectors = response.context["detectors"]
target_item = next((item for item in preview_detectors if item["code"] == "SWB01_B01_RA"), None)
self.assertIsNotNone(target_item)
self.assertEqual(target_item["status"], "modified")
self.assertEqual(target_item["db_id"], old_detector.id)
self.assertIn("Renommer le code", target_item["details_diff"])
# Integrate with checked checkbox
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_detector_SWB01_B01_RA": "on",
"action_detector_SWB01_B01_RA": "replace",
"db_id_detector_SWB01_B01_RA": str(old_detector.id)
})
self.assertEqual(response_integrate.status_code, 302)
# Old detector must now be archived
old_detector.refresh_from_db()
self.assertEqual(old_detector.status, "archived")
# A new replacement detector must be active with the updated code
new_active = TrafficLightDetector.objects.filter(intersection=self.intersection, pole=pole, code="SWB01_B01_RA", status="active").first()
self.assertIsNotNone(new_active)
self.assertEqual(old_detector.replaced_by, new_active)
def test_multiple_cross_plan_uploads(self):
import openpyxl
import tempfile
from assets.models import TrafficLightPole, TrafficLightLantern, TrafficLightDetector
# Workbook 1: Lantern A on pole B01
wb1 = openpyxl.Workbook()
ws1 = wb1.active
ws1.title = "kruisjesplan"
ws1.cell(row=1, column=1, value="Sleutel:")
ws1.cell(row=1, column=2, value="SWB01")
ws1.cell(row=5, column=1, value="Câble 01 SVAVB")
ws1.cell(row=6, column=3, value="richting")
ws1.cell(row=6, column=4, value="kringen")
ws1.cell(row=7, column=5, value="B01")
ws1.cell(row=8, column=1, value="com")
ws1.cell(row=8, column=2, value="1")
ws1.cell(row=8, column=3, value="A")
ws1.cell(row=8, column=4, value="")
ws1.cell(row=8, column=5, value="X")
# Workbook 2: Detector RC on pole B01
wb2 = openpyxl.Workbook()
ws2 = wb2.active
ws2.title = "kruisjesplan"
ws2.cell(row=1, column=1, value="Sleutel:")
ws2.cell(row=1, column=2, value="SWB01")
ws2.cell(row=5, column=1, value="Câble 02 TWAVB")
ws2.cell(row=6, column=3, value="richting")
ws2.cell(row=6, column=4, value="kringen")
ws2.cell(row=7, column=5, value="B01")
ws2.cell(row=8, column=1, value="com")
ws2.cell(row=8, column=2, value="1")
ws2.cell(row=8, column=3, value="RC")
ws2.cell(row=8, column=4, value="")
ws2.cell(row=8, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp1:
tmp1_path = tmp1.name
wb1.save(tmp1_path)
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp2:
tmp2_path = tmp2.name
wb2.save(tmp2_path)
try:
self.client.force_login(self.user)
from django.core.files.uploadedfile import SimpleUploadedFile
with open(tmp1_path, 'rb') as f1, open(tmp2_path, 'rb') as f2:
file1 = SimpleUploadedFile("plan_lanterns.xlsx", f1.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
file2 = SimpleUploadedFile("plan_detectors.xlsx", f2.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
# Upload both files
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
response = self.client.post(url_import, {
"cross_plan_file_upload": [file1, file2]
})
self.assertEqual(response.status_code, 200)
# Check that BOTH lanterns and detectors are in the preview context
preview_lanterns = response.context["lanterns"]
preview_detectors = response.context["detectors"]
# Lantern A should be present
self.assertTrue(any(l["code"] == "SWB01_B01_LAN01_A" for l in preview_lanterns))
# Detector RC should be present
self.assertTrue(any(d["code"] == "SWB01_B01_RC" for d in preview_detectors))
# Now integrate
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_B01": "on",
"action_pole_SWB01_B01": "add",
"selected_lantern_SWB01_B01_LAN01_A": "on",
"action_lantern_SWB01_B01_LAN01_A": "add",
"selected_detector_SWB01_B01_RC": "on",
"action_detector_SWB01_B01_RC": "add"
})
self.assertEqual(response_integrate.status_code, 302)
# Verify that both are created in the database
pole = TrafficLightPole.objects.filter(intersection=self.intersection, code="SWB01_B01", status="active").first()
self.assertIsNotNone(pole)
lantern = TrafficLightLantern.objects.filter(pole=pole, code="SWB01_B01_LAN01_A", status="active").first()
self.assertIsNotNone(lantern)
detector = TrafficLightDetector.objects.filter(intersection=self.intersection, pole=pole, code="SWB01_B01_RC", status="active").first()
self.assertIsNotNone(detector)
finally:
import os
for path in (tmp1_path, tmp2_path):
if os.path.exists(path):
os.remove(path)
def test_parse_cross_plan_french_and_new_codes(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook 1: Using Clé: and code SB403
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Plan de croix"
ws.cell(row=1, column=3, value="Clé:")
ws.cell(row=1, column=4, value="SB403")
ws.cell(row=2, column=1, value="Câble 01 SVAVB")
ws.cell(row=3, column=3, value="richting")
ws.cell(row=3, column=4, value="kringen")
ws.cell(row=4, column=5, value="B01")
ws.cell(row=5, column=1, value="com")
ws.cell(row=5, column=2, value="1")
ws.cell(row=5, column=3, value="A")
ws.cell(row=5, column=4, value="")
ws.cell(row=5, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
self.assertEqual(deduced["intersection_code"], "SB403")
self.assertEqual(len(deduced["poles"]), 1)
self.assertEqual(deduced["poles"][0]["code"], "SB403_B01")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_fallback_regex(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook 2: No label, but code SB403 in the first cells
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "kruisjesplan"
ws.cell(row=1, column=4, value="SB403")
ws.cell(row=2, column=1, value="Câble 01 SVAVB")
ws.cell(row=3, column=3, value="richting")
ws.cell(row=3, column=4, value="kringen")
ws.cell(row=4, column=5, value="B01")
ws.cell(row=5, column=1, value="com")
ws.cell(row=5, column=2, value="1")
ws.cell(row=5, column=3, value="A")
ws.cell(row=5, column=4, value="")
ws.cell(row=5, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
self.assertEqual(deduced["intersection_code"], "SB403")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_pushbuttons(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook 3: Testing various pushbutton nomenclatures (PBa1, BPf4, Dka)
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
# Row 8: PBa1
ws.cell(row=8, column=1, value="com")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="PBa1")
ws.cell(row=8, column=5, value="X")
# Row 9: BPf4
ws.cell(row=9, column=1, value="com")
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="BPf4")
ws.cell(row=9, column=5, value="X")
# Row 10: Dka
ws.cell(row=10, column=1, value="com")
ws.cell(row=10, column=2, value="3")
ws.cell(row=10, column=3, value="Dka")
ws.cell(row=10, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
detectors = deduced["detectors"]
self.assertEqual(len(detectors), 3)
# Check PBa1
pb_det = next((d for d in detectors if d["code"] == "SWB01_B01_PBa1"), None)
self.assertIsNotNone(pb_det)
self.assertEqual(pb_det["name"], "bouton poussoir NF (jaune) sur B01 - Phase a")
self.assertEqual(pb_det["model_name"], "Drukknop")
self.assertEqual(pb_det["phases"], ["a"])
# Check BPf4
bp_det = next((d for d in detectors if d["code"] == "SWB01_B01_BPf4"), None)
self.assertIsNotNone(bp_det)
self.assertEqual(bp_det["name"], "bouton poussoir NO (bleu) sur B01 - Phase f")
self.assertEqual(bp_det["model_name"], "Drukknop")
self.assertEqual(bp_det["phases"], ["f"])
# Check Dka
dk_det = next((d for d in detectors if d["code"] == "SWB01_B01_DKa"), None)
self.assertIsNotNone(dk_det)
self.assertEqual(dk_det["name"], "Drukknop sur B01 - Phase a")
self.assertEqual(dk_det["model_name"], "Drukknop")
self.assertEqual(dk_det["phases"], ["a"])
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_cross_plan_custom_names_and_pole_short_code(self):
import openpyxl
import tempfile
from assets.models import TrafficLightPole, TrafficLightDetector
# 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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
ws.cell(row=7, column=6, value="B02")
ws.cell(row=8, column=1, value="com")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="PBa1")
ws.cell(row=8, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
self.client.force_login(self.user)
from django.core.files.uploadedfile import SimpleUploadedFile
with open(tmp_path, 'rb') as f:
uploaded_file = SimpleUploadedFile("plan_custom_names.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
# 1. Preview
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)
# Check that pole defaults to short code name in preview
preview_poles = response.context["poles"]
b01_prev = next((p for p in preview_poles if p["code"] == "SWB01_B01"), None)
self.assertIsNotNone(b01_prev)
self.assertEqual(b01_prev["name"], "B01")
b02_prev = next((p for p in preview_poles if p["code"] == "SWB01_B02"), None)
self.assertIsNotNone(b02_prev)
self.assertEqual(b02_prev["name"], "B02")
# 2. Integrate with customized names
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_B01": "on",
"action_pole_SWB01_B01": "add",
"name_pole_SWB01_B01": "Mon Poteau B01",
"selected_pole_SWB01_B02": "on",
"action_pole_SWB01_B02": "add",
# no name override for B02, should default to "B02"
"selected_detector_SWB01_B01_PBa1": "on",
"action_detector_SWB01_B01_PBa1": "add",
"name_detector_SWB01_B01_PBa1": "Mon BP Jaune"
})
self.assertEqual(response_integrate.status_code, 302)
# Verify saved assets and names
p1 = TrafficLightPole.objects.filter(intersection=self.intersection, code="SWB01_B01", status="active").first()
self.assertIsNotNone(p1)
self.assertEqual(p1.name_fr, "Mon Poteau B01")
p2 = TrafficLightPole.objects.filter(intersection=self.intersection, code="SWB01_B02", status="active").first()
self.assertIsNotNone(p2)
self.assertEqual(p2.name_fr, "B02")
det = TrafficLightDetector.objects.filter(intersection=self.intersection, pole=p1, code="SWB01_B01_PBa1", status="active").first()
self.assertIsNotNone(det)
self.assertEqual(det.name, "Mon BP Jaune")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_import_cross_plan_from_existing_document(self):
from documents.models import DocumentTag, ManagedDocument, DocumentVersion, DocumentAttachment
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
import os
from django.conf import settings
# 1. Create the DocumentTag 'cross_plan'
tag, _ = DocumentTag.objects.get_or_create(
slug='cross_plan',
defaults={
'name_fr': 'Plan de croix',
'name_nl': 'Kruisjesplan',
'show_in_asset_detail': True
}
)
# 2. Create the ManagedDocument and version with real Excel
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("18ac47df841-2808f-DOC-23270.xlsx", excel_content, content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
doc = ManagedDocument.objects.create(
title="Plan de croix existant",
created_by=self.user
)
doc.tags.add(tag)
version = DocumentVersion.objects.create(
document=doc,
file=uploaded_file,
uploaded_by=self.user,
version_number=1
)
# 3. Attach to intersection
ct_tli = ContentType.objects.get_for_model(self.intersection.__class__)
DocumentAttachment.objects.create(
document=doc,
content_type=ct_tli,
object_id=self.intersection.id
)
self.client.force_login(self.user)
# 4. Check GET request contains the plan choice
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
response = self.client.get(url_import)
self.assertEqual(response.status_code, 200)
self.assertIn("cross_plan_choices", response.context)
choices = response.context["cross_plan_choices"]
self.assertTrue(any(c["id"] == f"managed-{doc.id}" for c in choices))
# 5. POST to analyze using existing plan
response_post = self.client.post(url_import, {
"existing_cross_plans": [f"managed-{doc.id}"]
})
self.assertEqual(response_post.status_code, 200)
self.assertEqual(response_post.context["step"], "preview")
self.assertTrue(len(response_post.context["poles"]) > 0)
def test_parse_cross_plan_pedestrian_hp(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook: Cyclo-pedestrian phase bF with HP line
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
# Row 8: Cyclo-pedestrian phase bF
ws.cell(row=8, column=1, value="com")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="bF")
ws.cell(row=8, column=5, value="X")
# Row 9: HP
ws.cell(row=9, column=1, value="com")
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="HP")
ws.cell(row=9, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
lanterns = deduced["lanterns"]
self.assertEqual(len(lanterns), 1)
self.assertEqual(lanterns[0]["model_name"], "2V200 cyclo-piéto + HP")
self.assertEqual(lanterns[0]["code"], "SWB01_B01_LAN01_bF")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_pure_pedestrian_hp(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook: Pure pedestrian phase 'a' with HP line
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
# Row 8: Pure pedestrian phase 'a'
ws.cell(row=8, column=1, value="com")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="a")
ws.cell(row=8, column=5, value="X")
# Row 9: HP
ws.cell(row=9, column=1, value="com")
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="HP")
ws.cell(row=9, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
lanterns = deduced["lanterns"]
self.assertEqual(len(lanterns), 1)
self.assertEqual(lanterns[0]["model_name"], "2V200 piéton+HP")
self.assertEqual(lanterns[0]["code"], "SWB01_B01_LAN01_a")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_hp_ls_common_wire_not_an_equipment(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook with pedestrian pole B01, vehicular-only pole A01,
# and a common wire HP/LS (42V) spanning across both poles.
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
ws.cell(row=7, column=6, value="A01")
# Row 8: Vehicle phase A on A01
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="A")
ws.cell(row=8, column=4, value="G")
ws.cell(row=8, column=6, value="X")
# Row 9: Pedestrian phase a on B01
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="a")
ws.cell(row=9, column=4, value="gr")
ws.cell(row=9, column=5, value="X")
# Row 10: Common wire HP/LS (42V) connected to both B01 and A01
ws.cell(row=10, column=2, value="55")
ws.cell(row=10, column=3, value="HP/LS")
ws.cell(row=10, column=4, value="42V")
ws.cell(row=10, column=5, value="X")
ws.cell(row=10, column=6, value="X")
# Row 11: Common wire RADAR (42V)
ws.cell(row=11, column=2, value="56")
ws.cell(row=11, column=3, value="RADAR")
ws.cell(row=11, column=4, value="42V")
ws.cell(row=11, column=5, value="X")
# Row 12: Common wire Com (42V)
ws.cell(row=12, column=2, value="58")
ws.cell(row=12, column=3, value="Com")
ws.cell(row=12, column=4, value="42V")
ws.cell(row=12, column=5, value="X")
ws.cell(row=12, column=6, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
lanterns = deduced["lanterns"]
detectors = deduced["detectors"]
# Exactly 2 lanterns: 1 vehicle for A on A01, 1 pedestrian for a on B01
self.assertEqual(len(lanterns), 2)
lan_a = next(l for l in lanterns if l["phase"] == "A")
self.assertEqual(lan_a["code"], "SWB01_A01_LAN01_A")
self.assertEqual(lan_a["model_name"], "3V200")
lan_ped = next(l for l in lanterns if l["phase"] == "a")
self.assertEqual(lan_ped["code"], "SWB01_B01_LAN01_a")
# B01 has HP/LS common wire, so pedestrian lantern gets piéton+HP
self.assertEqual(lan_ped["model_name"], "2V200 piéton+HP")
# Crucial assertion: NO equipment (lantern or detector) created for HP/LS, RADAR, or Com
self.assertFalse(any("HP" in l["code"] or "LS" in l["code"] for l in lanterns))
self.assertEqual(len(detectors), 0)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_double_cross_lanterns(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
# Workbook with pole B01 (with arm) and double crosses (XX) for vehicle phase A, pedestrian a, and tram T1
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
# Double crosses for vehicle phase A
ws.cell(row=8, column=3, value="A")
ws.cell(row=8, column=4, value="Y")
ws.cell(row=8, column=5, value="XX")
# Double crosses for pedestrian phase a
ws.cell(row=9, column=3, value="a")
ws.cell(row=9, column=4, value="g")
ws.cell(row=9, column=5, value="XX")
# Double crosses for tram phase T1
ws.cell(row=10, column=3, value="T1")
ws.cell(row=10, column=4, value="Y")
ws.cell(row=10, column=5, value="XX")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
lanterns = deduced["lanterns"]
# We expect two lanterns for A: SWB01_B01_LAN01_A (3V200) and SWB01_B01_LAN02_A (3V300)
lan01 = next((l for l in lanterns if l["code"] == "SWB01_B01_LAN01_A"), None)
self.assertIsNotNone(lan01)
self.assertEqual(lan01["model_name"], "3V200")
lan02 = next((l for l in lanterns if l["code"] == "SWB01_B01_LAN02_A"), None)
self.assertIsNotNone(lan02)
self.assertEqual(lan02["model_name"], "3V300")
# We expect two standard pedestrian lanterns for a: LAN05_a and LAN06_a (2V200)
lan_a1 = next((l for l in lanterns if l["code"] == "SWB01_B01_LAN05_a"), None)
self.assertIsNotNone(lan_a1)
self.assertEqual(lan_a1["model_name"], "2V200")
lan_a2 = next((l for l in lanterns if l["code"] == "SWB01_B01_LAN06_a"), None)
self.assertIsNotNone(lan_a2)
self.assertEqual(lan_a2["model_name"], "2V200")
# We expect two tram lanterns for T1: LAN03_T1 (3V200 tram) and LAN04_T1 (3V300 tram)
lan_t1 = next((l for l in lanterns if l["code"] == "SWB01_B01_LAN03_T1"), None)
self.assertIsNotNone(lan_t1)
self.assertEqual(lan_t1["model_name"], "3V200 tram tout droite")
lan_t2 = next((l for l in lanterns if l["code"] == "SWB01_B01_LAN04_T1"), None)
self.assertIsNotNone(lan_t2)
self.assertEqual(lan_t2["model_name"], "3V300 tram tout droit")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_detector_model_mapping(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
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="Câble 02 TWAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
# Row 8: RC (starts with R, but not Rp) -> should map to TM60
ws.cell(row=8, column=3, value="RC")
ws.cell(row=8, column=5, value="X")
# Row 9: RpA (starts with Rp) -> should map to TMA122
ws.cell(row=9, column=3, value="RpA")
ws.cell(row=9, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
detectors = deduced["detectors"]
self.assertEqual(len(detectors), 2)
det_rc = next((d for d in detectors if d["code"] == "SWB01_B01_RC"), None)
self.assertIsNotNone(det_rc)
self.assertEqual(det_rc["model_name"], "TM60")
det_rpa = next((d for d in detectors if d["code"] == "SWB01_B01_RPA"), None)
self.assertIsNotNone(det_rpa)
self.assertEqual(det_rpa["model_name"], "TMA-122 M")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_video_detectors(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
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="Câble 02 TWAVB")
# Header indicating a Detector section
ws.cell(row=6, column=3, value="Detecteur")
ws.cell(row=6, column=4, value="I/O")
ws.cell(row=7, column=5, value="B01")
# Row 8: VAF VT VA (starts with V) -> should map to Traficam wide angle
ws.cell(row=8, column=3, value="VAF VT VA")
ws.cell(row=8, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
detectors = deduced["detectors"]
self.assertEqual(len(detectors), 1)
det_video = next((d for d in detectors if d["code"] == "SWB01_B01_VAFVTVA"), None)
self.assertIsNotNone(det_video)
self.assertEqual(det_video["model_name"], "Traficam wide angle")
# Verify no lanterns were created since it was in the Detecteur section
self.assertEqual(len(deduced["lanterns"]), 0)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_cable_header_any_column(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "kruisjesplan"
ws.cell(row=1, column=3, value="sleutel:")
ws.cell(row=1, column=4, value="SAT110")
# Cable header in column 7
ws.cell(row=2, column=7, value="Câble 01 SVAVB 30x1,5mm²")
ws.cell(row=4, column=1, value="Klem controller")
ws.cell(row=4, column=2, value="Klemnummer")
ws.cell(row=4, column=3, value="Richting")
ws.cell(row=4, column=4, value="Kringen")
ws.cell(row=5, column=5, value="A01")
ws.cell(row=5, column=6, value="A02")
# Row 6: Phase B
ws.cell(row=6, column=2, value=1)
ws.cell(row=6, column=3, value="B")
ws.cell(row=6, column=4, value="RS1")
ws.cell(row=6, column=5, value="X")
ws.cell(row=6, column=6, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
self.assertEqual(deduced["intersection_code"], "SAT110")
self.assertEqual(len(deduced["poles"]), 2)
self.assertEqual(len(deduced["cables"]), 1)
self.assertEqual(deduced["cables"][0]["strands"], 30)
self.assertEqual(deduced["cables"][0]["code"], "SAT110_CAB01")
self.assertEqual(len(deduced["lanterns"]), 2)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_parse_cross_plan_radar_power_ignored_and_detector_names(self):
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
from assets.models import TrafficLightDetector
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "kruisjesplan"
ws.cell(row=1, column=3, value="sleutel:")
ws.cell(row=1, column=4, value="SAT110")
ws.cell(row=2, column=7, value="Câble 01 SVAVB 30x1,5mm²")
ws.cell(row=4, column=1, value="Klem controller")
ws.cell(row=4, column=2, value="Klemnummer")
ws.cell(row=4, column=3, value="Richting")
ws.cell(row=4, column=4, value="Kringen")
ws.cell(row=5, column=5, value="A01")
# Row 6: RADAR power line -> should be IGNORED
ws.cell(row=6, column=2, value=1)
ws.cell(row=6, column=3, value="RADAR")
ws.cell(row=6, column=4, value="RADAR")
ws.cell(row=6, column=5, value="X")
# Row 7: RB2 radar detector -> should be parsed as detector
ws.cell(row=7, column=2, value=2)
ws.cell(row=7, column=3, value="RB2")
ws.cell(row=7, column=4, value="RB2")
ws.cell(row=7, column=5, value="X")
# Row 8: Push button BPb1 -> should be parsed as push button with name and phase
ws.cell(row=8, column=2, value=3)
ws.cell(row=8, column=3, value="BPb1")
ws.cell(row=8, column=4, value="DK b1")
ws.cell(row=8, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
detectors = deduced["detectors"]
self.assertEqual(len(detectors), 2)
# Verify RADAR was NOT included
codes = [d["code"] for d in detectors]
self.assertNotIn("SAT110_A01_RADAR", codes)
self.assertIn("SAT110_A01_RB2", codes)
self.assertIn("SAT110_A01_BPb1", codes)
bp = next(d for d in detectors if d["code"] == "SAT110_A01_BPb1")
self.assertEqual(bp["phases"], ["b"])
self.assertIn("bouton poussoir", bp["name"])
# Test model get_name synchronization
from assets.models import TrafficLightDetectorModel
det_model = TrafficLightDetectorModel.objects.first()
det = TrafficLightDetector(intersection=self.intersection, name=bp["name"], code=bp["code"], model=det_model)
det.save()
self.assertEqual(det.name_fr, bp["name"])
self.assertEqual(det.get_name(), bp["name"])
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_integrate_cross_plan_update_all(self):
import openpyxl
import tempfile
from django.contrib.gis.geos import Point as DjangoPoint
from assets.models import TrafficLightPole, TrafficLightPoleModel, TrafficLightLantern
pole_model = TrafficLightPoleModel.objects.first()
# 1. Existing pole B01 in DB (will be updated)
existing_pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_B01",
name_fr="Ancien B01",
geom=DjangoPoint(149250.0, 170550.0, srid=3812),
lon=4.35,
lat=50.84,
model=pole_model,
status="active"
)
# 2. Existing untouched pole B99 in DB (should remain active after update_all)
untouched_pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_B99",
name_fr="Poteau Untouched",
model=pole_model,
status="active"
)
# 3. Create cross plan workbook containing B01 (with new coordinates) and B02 (new pole)
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
ws.cell(row=7, column=6, value="B02")
ws.cell(row=8, column=1, value="com")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="A")
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)
try:
self.client.force_login(self.user)
from django.core.files.uploadedfile import SimpleUploadedFile
with open(tmp_path, 'rb') as f:
uploaded_file = SimpleUploadedFile("update_plan.xlsx", f.read(), 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)
# Call integrate with global_action="update_all"
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"],
"global_action": "update_all",
"name_pole_SWB01_B01": "B01 Mis à jour",
"x_3812_pole_SWB01_B01": "149260.0",
"y_3812_pole_SWB01_B01": "170560.0",
"lon_pole_SWB01_B01": "4.351",
"lat_pole_SWB01_B01": "50.841",
"name_pole_SWB01_B02": "Nouveau B02",
"x_3812_pole_SWB01_B02": "149300.0",
"y_3812_pole_SWB01_B02": "170600.0",
})
self.assertEqual(response_integrate.status_code, 302)
# Verify:
# - existing_pole should still have status="active", ID unchanged, name updated, geometry updated
existing_pole.refresh_from_db()
self.assertEqual(existing_pole.status, "active")
self.assertEqual(existing_pole.name_fr, "B01 Mis à jour")
self.assertEqual(existing_pole.geom.x, 149260.0)
self.assertEqual(existing_pole.geom.y, 170560.0)
# - untouched_pole should still be active
untouched_pole.refresh_from_db()
self.assertEqual(untouched_pole.status, "active")
# - new pole B02 should be created active
new_pole = TrafficLightPole.objects.filter(intersection=self.intersection, code="SWB01_B02", status="active").first()
self.assertIsNotNone(new_pole)
self.assertEqual(new_pole.name_fr, "Nouveau B02")
self.assertEqual(new_pole.geom.x, 149300.0)
# - lantern on B01 and lantern on B02 should be active
l1 = TrafficLightLantern.objects.filter(pole=existing_pole, status="active").first()
self.assertIsNotNone(l1)
l2 = TrafficLightLantern.objects.filter(pole=new_pole, status="active").first()
self.assertIsNotNone(l2)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_import_cross_plan_dxf_geometry_diff(self):
import openpyxl
import tempfile
from django.contrib.gis.geos import Point as DjangoPoint
from unittest.mock import patch
from assets.models import TrafficLightPole, TrafficLightPoleModel
pole_model = TrafficLightPoleModel.objects.first()
# Existing pole B01 with initial coordinates in DB
existing_pole = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_B01",
geom=DjangoPoint(149200.0, 170500.0, srid=3812),
lon=4.350,
lat=50.840,
model=pole_model,
status="active"
)
# Cross plan workbook with pole B01
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
ws.cell(row=8, column=3, value="A")
ws.cell(row=8, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
# Mock DXF poles returned with a 2.5m shift (x + 2.0, y + 1.5 -> distance = 2.5m)
mock_dxf_poles = [{
'label': 'B01',
'lon': 4.35003,
'lat': 50.84002,
'x_3812': 149202.0,
'y_3812': 170501.5
}]
try:
self.client.force_login(self.user)
from django.core.files.uploadedfile import SimpleUploadedFile
with open(tmp_path, 'rb') as f:
uploaded_file = SimpleUploadedFile("dxf_diff_plan.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
with patch('assets.views.trafficlights.resolve_dxf_file_path', return_value='/dummy/path.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)
preview_poles = response.context["poles"]
b01 = next(p for p in preview_poles if p["code"] == "SWB01_B01")
self.assertTrue(b01["geom_modified"])
self.assertEqual(b01["distance_diff"], 2.5)
self.assertEqual(b01["status"], "modified")
self.assertEqual(b01["db_x_3812"], 149200.0)
self.assertEqual(b01["new_x_3812"], 149202.0)
self.assertTrue(any("Position modifiée (DXF)" in d for d in b01["details_diff"]))
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_match_pole_lanterns_intelligence_user_scenario(self):
"""
Tests the intelligent matching of lanterns when multiple lanterns are on the same pole.
In the user scenario:
- DB has:
LAN01: 3V200 tram tout droite
LAN02: 3V300 fl gauche
- Plan deduces:
LAN01_B1: 3V300 (vehicular phase B1)
LAN02_T2: 3V300 tram tout droit (tram phase T2)
The algorithm must NOT naively match LAN01_B1 to DB LAN01 and LAN02_T2 to DB LAN02.
Instead, it must match:
LAN01_B1 -> DB LAN02 (vehicular to vehicular)
LAN02_T2 -> DB LAN01 (tram to tram)
"""
from assets.utils.cross_plan_parser import match_pole_lanterns
pole_dls = [
{'code': 'SEK02_B02_LAN01_B1', 'model_name': '3V300', 'name': '3V300 sur B02 - Phase B1', 'phase': 'B1', 'pole_code': 'SEK02_B02'},
{'code': 'SEK02_B02_LAN02_T2', 'model_name': '3V300 tram tout droit', 'name': '3V300 tram sur B02 - Phase T2', 'phase': 'T2', 'pole_code': 'SEK02_B02'}
]
class DummyModel:
def __init__(self, name_fr, name_nl=''):
self.name_fr = name_fr
self.name_nl = name_nl
class DummyLantern:
def __init__(self, code, name_fr, model_name):
self.code = code
self.name_fr = name_fr
self.model = DummyModel(model_name)
el_tram = DummyLantern('SEK02_B02_LAN01', '3V200 tram tout droite', '3V200 tram tout droite')
el_car = DummyLantern('SEK02_B02_LAN02', '3V300 fl gauche', '3V300 fl gauche')
pole_els = [el_tram, el_car]
matched_pairs, unmatched_dls, unmatched_els = match_pole_lanterns(pole_dls, pole_els)
self.assertEqual(len(matched_pairs), 2)
self.assertEqual(len(unmatched_dls), 0)
self.assertEqual(len(unmatched_els), 0)
match_map = {dl['code']: el.code for dl, el in matched_pairs}
self.assertEqual(match_map['SEK02_B02_LAN01_B1'], 'SEK02_B02_LAN02')
self.assertEqual(match_map['SEK02_B02_LAN02_T2'], 'SEK02_B02_LAN01')
def test_match_pole_lanterns_cyclopieton_bf(self):
"""
Tests the user scenario on pole B01 with multiple lanterns:
DB has:
LAN01_A (3V200)
LAN02_A (3V300)
LAN03 (2V200 cyclo-piéto + HP)
Plan deduces:
LAN01_A (3V200)
LAN02_A (3V300)
LAN03_bF (2V200 cyclo-piéto + HP, phase bF)
Must match LAN03_bF -> DB LAN03 instead of archiving LAN03 and creating a new asset.
"""
from assets.utils.cross_plan_parser import match_pole_lanterns
class DummyModel:
def __init__(self, name_fr):
self.name_fr = name_fr
self.name_nl = ''
class DummyLantern:
def __init__(self, code, name_fr, model_name):
self.code = code
self.name_fr = name_fr
self.model = DummyModel(model_name)
pole_dls = [
{'code': 'SPW10_B01_LAN01_A', 'model_name': '3V200', 'name': '3V200 sur B01 - Phase A', 'phase': 'A', 'pole_code': 'SPW10_B01'},
{'code': 'SPW10_B01_LAN02_A', 'model_name': '3V300', 'name': '3V300 sur B01 - Phase A', 'phase': 'A', 'pole_code': 'SPW10_B01'},
{'code': 'SPW10_B01_LAN03_bF', 'model_name': '2V200 cyclo-piéto + HP', 'name': '2V200 cyclo-piéto + HP sur B01 - Phase bF', 'phase': 'bF', 'pole_code': 'SPW10_B01'},
]
el_v1 = DummyLantern('SPW10_B01_LAN01_A', '3V200', '3V200')
el_v2 = DummyLantern('SPW10_B01_LAN02_A', '3V300', '3V300')
el_cyclo = DummyLantern('SPW10_B01_LAN03', '2V200 cyclo-piéto + HP', '2V200 cyclo-piéto + HP')
pole_els = [el_v1, el_v2, el_cyclo]
matched_pairs, unmatched_dls, unmatched_els = match_pole_lanterns(pole_dls, pole_els)
self.assertEqual(len(matched_pairs), 3)
self.assertEqual(len(unmatched_dls), 0)
self.assertEqual(len(unmatched_els), 0)
match_map = {dl['code']: el.code for dl, el in matched_pairs}
self.assertEqual(match_map['SPW10_B01_LAN03_bF'], 'SPW10_B01_LAN03')
def test_import_cross_plan_stib_detector_preserved_by_default(self):
"""
Tests that when a DB detector is named dT1 or dT2 (STIB tram loop),
the preview recognizes it as STIB, sets action_default='keep', and proposes
to preserve it by default in the HTML decision select box.
"""
import openpyxl
import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from assets.models import TrafficLightDetector, TrafficLightDetectorModel
det_model = TrafficLightDetectorModel.objects.first()
stib_det = TrafficLightDetector.objects.create(
intersection=self.intersection,
code="SPW10_DET_1",
name="dT1",
name_fr="dT1",
model=det_model,
status="active"
)
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B01")
ws.cell(row=8, column=3, value="A")
ws.cell(row=8, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
self.client.force_login(self.user)
with open(tmp_path, "rb") as f:
uploaded = SimpleUploadedFile("test_cross_plan.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
url = reverse("assets:import_cross_plan", args=[self.intersection.id])
response = self.client.post(url, {"cross_plan_file_upload": uploaded})
self.assertEqual(response.status_code, 200)
detectors = response.context["detectors"]
stib_item = next((d for d in detectors if d["code"] == "SPW10_DET_1"), None)
self.assertIsNotNone(stib_item)
self.assertEqual(stib_item["status"], "db_only")
self.assertEqual(stib_item["action_default"], "keep")
self.assertTrue(stib_item["is_stib"])
self.assertIn("STIB", stib_item["details_diff"][0])
# Verify in HTML that 'Conserver en DB' is selected
html = response.content.decode("utf-8")
self.assertIn('name="action_detector_db_SPW10_DET_1"', html)
self.assertIn('value="keep" selected', html)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_import_cross_plan_multiple_lanterns_preview(self):
"""
End-to-end view test with multiple lanterns on the same pole.
Verifies that import_cross_plan view correctly associates the lanterns in compared_lanterns
and populates the preview with the correct db_id and diffs.
"""
import openpyxl
import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from assets.models import TrafficLightPole, TrafficLightLantern, TrafficLightPoleModel, TrafficLightLanternModel, AssetCategory
pole_model = TrafficLightPoleModel.objects.first()
m_tram, _ = TrafficLightLanternModel.objects.get_or_create(code="3v200_tram", defaults={"name_fr": "3V200 tram tout droite", "voltage": 230})
m_turn, _ = TrafficLightLanternModel.objects.get_or_create(code="3v300_fl", defaults={"name_fr": "3V300 fl gauche", "voltage": 230})
TrafficLightLanternModel.objects.get_or_create(code="3v300_tram", defaults={"name_fr": "3V300 tram tout droit", "voltage": 230})
TrafficLightLanternModel.objects.get_or_create(code="3v300", defaults={"name_fr": "3V300", "voltage": 230})
category_pole = AssetCategory.objects.filter(code="TL_POLE").first()
category_lan = AssetCategory.objects.filter(code="TL_LANTERN").first() or AssetCategory.objects.filter(code="TL_LANTERNE").first()
pole_b02 = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_B02",
model=pole_model,
category=category_pole,
status="active"
)
db_lan_tram = TrafficLightLantern.objects.create(
pole=pole_b02,
code="SWB01_B02_LAN01",
model=m_tram,
name_fr="3V200 tram tout droite",
category=category_lan,
status="active"
)
db_lan_car = TrafficLightLantern.objects.create(
pole=pole_b02,
code="SWB01_B02_LAN02",
model=m_turn,
name_fr="3V300 fl gauche",
category=category_lan,
status="active"
)
# Create Excel cross plan with pole B02 having phase B1 (vehicle) and T2 (tram)
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="B02")
# Row 8: Phase B1 (Vehicle)
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="B1")
ws.cell(row=8, column=4, value="Y")
ws.cell(row=8, column=5, value="X")
# Row 9: Phase T2 (Tram)
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="T2")
ws.cell(row=9, column=4, value="Y")
ws.cell(row=9, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
self.client.force_login(self.user)
with open(tmp_path, "rb") as f:
uploaded_file = SimpleUploadedFile("test_multi_lantern.xlsx", f.read(), 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)
lanterns = response.context["lanterns"]
lan_b1 = next(l for l in lanterns if "LAN01_B1" in l["code"])
lan_t2 = next(l for l in lanterns if "LAN02_T2" in l["code"])
# Verify that LAN01_B1 is paired with DB LAN02 (db_lan_car.id)
self.assertEqual(lan_b1["db_id"], db_lan_car.id)
self.assertEqual(lan_b1["status"], "modified")
self.assertTrue(any(f"Code : {db_lan_car.code} → {lan_b1['code']}" in d for d in lan_b1["details_diff"]))
# Verify that LAN02_T2 is paired with DB LAN01 (db_lan_tram.id)
self.assertEqual(lan_t2["db_id"], db_lan_tram.id)
self.assertEqual(lan_t2["status"], "modified")
self.assertTrue(any(f"Code : {db_lan_tram.code} → {lan_t2['code']}" in d for d in lan_t2["details_diff"]))
finally:
import os
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)
def test_parse_cross_plan_com_and_alim_wires_ignored_in_detection_sections(self):
"""
Verify that auxiliary wiring rows like 'Com', 'Comm', 'Alim', 'Contact', 'Radar'
in detection cable / Detecteur sections are not parsed as detectors or lanterns.
This fixes the bug where 'Com' lines were erroneously created as 'Traficam wide angle' detectors.
"""
import openpyxl
import tempfile
from assets.utils.cross_plan_parser import parse_cross_plan
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="Câble 48 TWAVB 10x2x0,8 mm²")
ws.cell(row=6, column=3, value="Detecteur")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="A01")
ws.cell(row=7, column=6, value="A05")
# Row 8: Com on A01
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="Com")
ws.cell(row=8, column=4, value="COM")
ws.cell(row=8, column=5, value="X")
# Row 9: RpC on A01
ws.cell(row=9, column=2, value="2")
ws.cell(row=9, column=3, value="RpC")
ws.cell(row=9, column=4, value="TMA-122 M")
ws.cell(row=9, column=5, value="X")
# Row 10: Alim on A05
ws.cell(row=10, column=2, value="3")
ws.cell(row=10, column=3, value="Alim")
ws.cell(row=10, column=4, value="24V")
ws.cell(row=10, column=6, value="X")
# Row 11: RpB on A05
ws.cell(row=11, column=2, value="4")
ws.cell(row=11, column=3, value="RpB")
ws.cell(row=11, column=4, value="TMA-122 M")
ws.cell(row=11, column=6, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
deduced = parse_cross_plan(tmp_path)
detectors = deduced["detectors"]
detector_codes = [d["code"] for d in detectors]
# Only RpC on A01 and RpB on A05 should be recognized
self.assertEqual(len(detectors), 2)
self.assertIn("SWB01_A01_RPC", detector_codes)
self.assertIn("SWB01_A05_RPB", detector_codes)
# Neither Com nor Alim should be in detectors
self.assertNotIn("SWB01_A01_COM", detector_codes)
self.assertNotIn("SWB01_A05_ALIM", detector_codes)
self.assertFalse(any("COM" in code for code in detector_codes))
self.assertFalse(any("ALIM" in code for code in detector_codes))
self.assertEqual(len(deduced["lanterns"]), 0)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_integrate_cross_plan_archive_and_delete_actions(self):
"""
Verify that users can choose 'archive' or 'delete' for assets during cross plan integration:
- When 'archive' is selected, the asset's status becomes 'archived'.
- When 'delete' is selected, the asset is removed from the database.
"""
import openpyxl
import tempfile
from assets.models import (
TrafficLightPole, TrafficLightLantern, TrafficLightDetector,
TrafficLightCable, TrafficLightPoleModel, TrafficLightLanternModel,
TrafficLightDetectorModel, TrafficLightCableModel
)
from assets.models.sign import SignPanel, SignPanelModel
pole_model = TrafficLightPoleModel.objects.first()
lantern_model = TrafficLightLanternModel.objects.first()
detector_model = TrafficLightDetectorModel.objects.first()
cable_model = TrafficLightCableModel.objects.first()
sign_model, _ = SignPanelModel.objects.get_or_create(code="sp_test_mod", defaults={"name_fr": "Model SP Test"})
# Assets to delete
pole_del = TrafficLightPole.objects.create(intersection=self.intersection, code="SWB01_P_DEL", model=pole_model, status="active")
lan_del = TrafficLightLantern.objects.create(pole=pole_del, code="SWB01_L_DEL", model=lantern_model, status="active")
det_del = TrafficLightDetector.objects.create(intersection=self.intersection, code="SWB01_D_DEL", model=detector_model, status="active")
cab_del = TrafficLightCable.objects.create(intersection=self.intersection, code="SWB01_C_DEL", model=cable_model, status="active")
sp_del = SignPanel.objects.create(trafficlight_pole=pole_del, code="SWB01_SP_DEL", model=sign_model, status="active")
pole_standalone_del = TrafficLightPole.objects.create(intersection=self.intersection, code="SWB01_P_STANDALONE_DEL", model=pole_model, status="active")
# Assets to archive
pole_arc = TrafficLightPole.objects.create(intersection=self.intersection, code="SWB01_P_ARC", model=pole_model, status="active")
lan_arc = TrafficLightLantern.objects.create(pole=pole_arc, code="SWB01_L_ARC", model=lantern_model, status="active")
det_arc = TrafficLightDetector.objects.create(intersection=self.intersection, pole=pole_arc, code="SWB01_D_ARC", model=detector_model, status="active")
cab_arc = TrafficLightCable.objects.create(intersection=self.intersection, code="SWB01_C_ARC", model=cable_model, status="active")
sp_arc = SignPanel.objects.create(trafficlight_pole=pole_arc, code="SWB01_SP_ARC", model=sign_model, status="active")
# Minimal workbook so we can run import_cross_plan
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="Câble 01 SVAVB")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="P_ARC")
ws.cell(row=8, column=3, value="A")
ws.cell(row=8, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
self.client.force_login(self.user)
from django.core.files.uploadedfile import SimpleUploadedFile
with open(tmp_path, "rb") as f:
uploaded_file = SimpleUploadedFile("plan_test_archive_delete.xlsx", f.read(), 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)
# Test POST integrate with delete on db-only assets and archive on others
url_integrate = reverse("assets:integrate_cross_plan", args=[self.intersection.id])
post_data = {
"cross_plan_filename": response.context["cross_plan_filename"],
# Delete DB-only standalone pole
"selected_pole_db_SWB01_P_STANDALONE_DEL": "on",
"action_pole_db_SWB01_P_STANDALONE_DEL": "delete",
"db_id_pole_SWB01_P_STANDALONE_DEL": str(pole_standalone_del.id),
# Delete DB-only lantern
"selected_lantern_db_SWB01_L_DEL": "on",
"action_lantern_db_SWB01_L_DEL": "delete",
"db_id_lantern_SWB01_L_DEL": str(lan_del.id),
# Delete DB-only detector
"selected_detector_db_SWB01_D_DEL": "on",
"action_detector_db_SWB01_D_DEL": "delete",
"db_id_detector_SWB01_D_DEL": str(det_del.id),
# Delete DB-only cable
"selected_cable_db_SWB01_C_DEL": "on",
"action_cable_db_SWB01_C_DEL": "delete",
"db_id_cable_SWB01_C_DEL": str(cab_del.id),
# Delete other asset (signpanel)
f"selected_other_signpanel_{sp_del.id}": "on",
f"action_other_signpanel_{sp_del.id}": "delete",
# Archive DB-only pole
"selected_pole_db_SWB01_P_ARC": "on",
"action_pole_db_SWB01_P_ARC": "archive",
"db_id_pole_SWB01_P_ARC": str(pole_arc.id),
# Archive DB-only lantern
"selected_lantern_db_SWB01_L_ARC": "on",
"action_lantern_db_SWB01_L_ARC": "archive",
"db_id_lantern_SWB01_L_ARC": str(lan_arc.id),
# Archive DB-only detector
"selected_detector_db_SWB01_D_ARC": "on",
"action_detector_db_SWB01_D_ARC": "archive",
"db_id_detector_SWB01_D_ARC": str(det_arc.id),
# Archive DB-only cable
"selected_cable_db_SWB01_C_ARC": "on",
"action_cable_db_SWB01_C_ARC": "archive",
"db_id_cable_SWB01_C_ARC": str(cab_arc.id),
# Archive other asset (signpanel)
f"selected_other_signpanel_{sp_arc.id}": "on",
f"action_other_signpanel_{sp_arc.id}": "archive",
}
response_integrate = self.client.post(url_integrate, post_data)
self.assertEqual(response_integrate.status_code, 302)
# Verification of deleted items
self.assertFalse(TrafficLightPole.objects.filter(pk=pole_standalone_del.id).exists())
self.assertFalse(TrafficLightLantern.objects.filter(pk=lan_del.id).exists())
self.assertFalse(TrafficLightDetector.objects.filter(pk=det_del.id).exists())
self.assertFalse(TrafficLightCable.objects.filter(pk=cab_del.id).exists())
self.assertFalse(SignPanel.objects.filter(pk=sp_del.id).exists())
# Verification of archived items
pole_arc.refresh_from_db()
self.assertEqual(pole_arc.status, "archived")
lan_arc.refresh_from_db()
self.assertEqual(lan_arc.status, "archived")
det_arc.refresh_from_db()
self.assertEqual(det_arc.status, "archived")
cab_arc.refresh_from_db()
self.assertEqual(cab_arc.status, "archived")
sp_arc.refresh_from_db()
self.assertEqual(sp_arc.status, "archived")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_integrate_cross_plan_plan_asset_archive_and_delete_actions(self):
"""
Verify that for assets that exist both in DB and in the plan (status identical or modified),
the user can select 'archive' (archiver l'existant sans recréer) or 'delete' (supprimer l'existant de la DB).
"""
import openpyxl
import tempfile
from assets.models import (
TrafficLightPole, TrafficLightLantern, TrafficLightDetector,
TrafficLightCable, TrafficLightPoleModel, TrafficLightLanternModel,
TrafficLightDetectorModel, TrafficLightCableModel
)
from django.core.files.uploadedfile import SimpleUploadedFile
pole_model = TrafficLightPoleModel.objects.first()
lantern_model = TrafficLightLanternModel.objects.first()
detector_model = TrafficLightDetectorModel.objects.first()
cable_model = TrafficLightCableModel.objects.first()
existing_pole = TrafficLightPole.objects.create(intersection=self.intersection, code="SWB01_A01", model=pole_model, status="active")
existing_lantern = TrafficLightLantern.objects.create(pole=existing_pole, code="SWB01_A01_LAN01_A", model=lantern_model, status="active")
existing_detector = TrafficLightDetector.objects.create(intersection=self.intersection, code="SWB01_A01_RPC", model=detector_model, status="active")
existing_cable = TrafficLightCable.objects.create(intersection=self.intersection, code="SWB01_CAB01", model=cable_model, status="active")
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="Câble 01 SVAVB")
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=8, column=3, value="A")
ws.cell(row=8, column=5, value="X")
ws.cell(row=9, column=3, value="RpC")
ws.cell(row=9, column=5, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
try:
self.client.force_login(self.user)
with open(tmp_path, "rb") as f:
uploaded_file = SimpleUploadedFile("plan_test_plan_actions.xlsx", f.read(), 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:
# - detector: action="delete"
# - lantern: action="archive"
# - cable: action="delete"
# - pole: action="archive"
url_integrate = reverse("assets:integrate_cross_plan", args=[self.intersection.id])
post_data = {
"cross_plan_filename": response.context["cross_plan_filename"],
"selected_detector_SWB01_A01_RPC": "on",
"action_detector_SWB01_A01_RPC": "delete",
"db_id_detector_SWB01_A01_RPC": str(existing_detector.id),
"selected_lantern_SWB01_A01_LAN01_A": "on",
"action_lantern_SWB01_A01_LAN01_A": "archive",
"db_id_lantern_SWB01_A01_LAN01_A": str(existing_lantern.id),
"selected_cable_SWB01_CAB01": "on",
"action_cable_SWB01_CAB01": "delete",
"db_id_cable_SWB01_CAB01": str(existing_cable.id),
"selected_pole_SWB01_A01": "on",
"action_pole_SWB01_A01": "archive",
"db_id_pole_SWB01_A01": str(existing_pole.id),
}
response_integrate = self.client.post(url_integrate, post_data)
self.assertEqual(response_integrate.status_code, 302)
# Detector must be deleted
self.assertFalse(TrafficLightDetector.objects.filter(pk=existing_detector.id).exists())
# Cable must be deleted
self.assertFalse(TrafficLightCable.objects.filter(pk=existing_cable.id).exists())
# Lantern must be archived
existing_lantern.refresh_from_db()
self.assertEqual(existing_lantern.status, "archived")
# Pole must be archived
existing_pole.refresh_from_db()
self.assertEqual(existing_pole.status, "archived")
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_compared_cables_db_coords_and_integrate_cable_geometry(self):
import openpyxl
import tempfile
import json
from django.contrib.gis.geos import LineString
from assets.models import TrafficLightCable, TrafficLightCableModel
from django.core.files.uploadedfile import SimpleUploadedFile
from pyproj import Transformer
cable_model = TrafficLightCableModel.objects.first()
transformer = Transformer.from_crs(4326, 3812, always_xy=True)
p1 = transformer.transform(4.3508, 50.8465)
p2 = transformer.transform(4.3518, 50.8475)
# Create active cable in DB with Lambert 3812 LineString
existing_cable = TrafficLightCable.objects.create(
intersection=self.intersection,
code="SWB01_CAB01",
model=cable_model,
status="active",
geom=LineString([p1, p2], srid=3812)
)
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="Câble 01 SVAVB 61x1,5mm²")
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=1, value="1")
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)
try:
self.client.force_login(self.user)
with open(tmp_path, "rb") as f:
uploaded_file = SimpleUploadedFile("test_cross_plan.xlsx", f.read(), 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)
cables_preview = response.context["cables"]
cab01 = next((c for c in cables_preview if c["code"] == "SWB01_CAB01"), None)
self.assertIsNotNone(cab01)
self.assertTrue(cab01["has_db_geom"])
self.assertIsNotNone(cab01["db_coords"])
self.assertEqual(len(cab01["db_coords"]), 2)
# Coordinates in WGS84 lon, lat
self.assertAlmostEqual(cab01["db_coords"][0][0], 4.35, delta=0.5)
# Test integrate with modified cable geometry
url_integrate = reverse("assets:integrate_cross_plan", args=[self.intersection.id])
new_geom_coords = [[4.3508, 50.8465], [4.3510, 50.8470], [4.3515, 50.8475]]
post_data = {
"cross_plan_filename": response.context["cross_plan_filename"],
"selected_cable_SWB01_CAB01": "on",
"action_cable_SWB01_CAB01": "modify",
"db_id_cable_SWB01_CAB01": str(existing_cable.id),
"geom_cable_SWB01_CAB01": json.dumps(new_geom_coords),
}
res_int = self.client.post(url_integrate, post_data)
self.assertEqual(res_int.status_code, 302)
existing_cable.refresh_from_db()
self.assertIsNotNone(existing_cable.geom)
self.assertEqual(len(existing_cable.geom.coords), 3)
self.assertEqual(existing_cable.geom.srid, 3812)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
class AssetDefaultPositionTest(TestCase):
def test_assign_default_position_hierarchy(self):
from django.contrib.gis.geos import Polygon, MultiPolygon
from assets.models.structures import StructureLocation, Structure, StructureGeoAsset, StructureAssetModel
from assets.views.asset_editing import _assign_default_position
# Create a parent location with geometry
parent_geom = MultiPolygon(
Polygon(((0, 0), (0, 10), (10, 10), (10, 0), (0, 0))),
srid=3812
)
parent_loc = StructureLocation.objects.create(
name_fr="Parent Location",
geom=parent_geom,
lon=5.0,
lat=50.0
)
# Create a child location without geometry
child_loc = StructureLocation.objects.create(
name_fr="Child Location",
parent_location=parent_loc
)
# Create structure
structure = Structure.objects.create(
name_fr="Structure Test",
main_location=parent_loc
)
child_loc.structure = structure
child_loc.save()
# Create model
model = StructureAssetModel.objects.create(
name_fr="Model Test"
)
# Create asset without geometry initially
asset = StructureGeoAsset(
code="TEST-001",
model=model,
location=child_loc,
structure=structure
)
# Call _assign_default_position
_assign_default_position(asset, child_loc)
# Assert coordinates/geometry are set from parent location
self.assertIsNotNone(asset.geom)
self.assertEqual(asset.geom.srid, 3812)
self.assertEqual(asset.geom.x, parent_geom.centroid.x)
self.assertEqual(asset.geom.y, parent_geom.centroid.y)
self.assertIsNotNone(asset.lon)
self.assertIsNotNone(asset.lat)
self.assertIsNotNone(asset.geojson)
class AssetValidationPermissionTest(TestCase):
def setUp(self):
from django.contrib.auth.models import User
from common.models import UserConfig, Thematic, UserThematics
from assets.models import AssetCategory
from assets.models.structures import StructureGeoAsset, StructureAssetModel
self.user = User.objects.create_user(username="validator_user", password="password")
self.user_config = UserConfig.objects.create(user=self.user, is_intern=True)
self.thematic = Thematic.objects.create(code="structures", name_fr="Ouvrages d'art")
self.user_thematic = UserThematics.objects.create(
user_config=self.user_config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True,
can_validate_assets=False # Start with validation disabled
)
self.category = AssetCategory.objects.create(
thematic=self.thematic,
code="CAT",
name_fr="Category"
)
self.model = StructureAssetModel.objects.create(name_fr="Model")
self.asset = StructureGeoAsset.objects.create(
code="TEST-VAL-01",
model=self.model,
category=self.category,
status="active"
)
def test_validation_permission(self):
from assets.permissions import can_validate_asset, get_allowed_update_fields_for_asset
# Without permission
self.assertFalse(can_validate_asset(self.user, self.asset))
fields = get_allowed_update_fields_for_asset(self.user, self.asset)
self.assertNotIn("validation_status", fields)
# With permission
self.user_thematic.can_validate_assets = True
self.user_thematic.save()
self.assertTrue(can_validate_asset(self.user, self.asset))
fields = get_allowed_update_fields_for_asset(self.user, self.asset)
self.assertIn("validation_status", fields)
def test_bulk_edit_assets(self):
import json
from django.urls import reverse
# Login/authenticate the user
self.client.force_login(self.user)
url = reverse('assets:bulk_edit_assets')
payload = {
"assets": [{"model": "structuregeoasset", "object_id": self.asset.pk}],
"field_name": "validation_status",
"new_value": "validated"
}
# 1. Without permission
response = self.client.post(
url,
data=json.dumps(payload),
content_type="application/json"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertEqual(data['updated_count'], 0)
self.assertEqual(data['skipped_count'], 1)
# 2. Enable permission
self.user_thematic.can_validate_assets = True
self.user_thematic.save()
response = self.client.post(
url,
data=json.dumps(payload),
content_type="application/json"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertEqual(data['updated_count'], 1)
self.assertEqual(data['skipped_count'], 0)
# Verify database update
self.asset.refresh_from_db()
self.assertEqual(self.asset.validation_status, 'validated')
@tag('slow', 'dxf')
@skipUnless(RUN_DXF_TESTS, "Skip slow DXF tests by default")
class TrafficLightDXFLayerSelectionTest(TestCase):
def setUp(self):
from common.models import Thematic, UserConfig, UserThematics
from assets.models import TrafficLightIntersection
self.thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={
"name_fr": "Feux de signalisation",
"name_nl": "Verkeerslichten"
}
)
self.user = User.objects.create_user(username="testuser", password="password123")
self.config = UserConfig.objects.create(user=self.user, limit_assets_to_contracts=False)
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True
)
self.intersection = TrafficLightIntersection.objects.create(
code="SB0239",
name_fr="Carrefour SB0239",
status="active"
)
# Create a real temporary DXF file in settings.PRIVATE_MEDIA_ROOT / 'dxf_files'
import os
import ezdxf
from django.conf import settings
self.dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files')
os.makedirs(self.dxf_dir, exist_ok=True)
self.dxf_filename = "test_layers.dxf"
self.dxf_path = os.path.join(self.dxf_dir, self.dxf_filename)
# Create DXF with ezdxf and add some geometry in different layers
doc = ezdxf.new()
doc.layers.new('LAYER_A', dxfattribs={'color': 2})
doc.layers.new('LAYER_B', dxfattribs={'color': 5})
msp = doc.modelspace()
# Add entity to LAYER_A
msp.add_line((0, 0), (10, 10), dxfattribs={'layer': 'LAYER_A'})
# Add entity to LAYER_B
msp.add_line((10, 10), (20, 20), dxfattribs={'layer': 'LAYER_B'})
doc.saveas(self.dxf_path)
def tearDown(self):
import os
if os.path.exists(self.dxf_path):
try:
os.remove(self.dxf_path)
except Exception:
pass
# Also clean up the cached layouts created by tests
from django.conf import settings
cache_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_layouts')
if os.path.exists(cache_dir):
import glob
for f in glob.glob(os.path.join(cache_dir, "test_layers.*")):
try:
os.remove(f)
except Exception:
pass
def test_dxf_layers_endpoint(self):
self.client.force_login(self.user)
url = reverse("assets:dxf_layers", args=[self.intersection.id])
response = self.client.get(url, {"file": self.dxf_filename})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("layers", data)
layers = data["layers"]
# Should have LAYER_A and LAYER_B
layer_names = [l["name"] for l in layers]
self.assertIn("LAYER_A", layer_names)
self.assertIn("LAYER_B", layer_names)
# Colors should match (LAYER_A color ACI 2 is yellow #ffff00, LAYER_B ACI 5 is blue #0000ff)
layer_a = next(l for l in layers if l["name"] == "LAYER_A")
layer_b = next(l for l in layers if l["name"] == "LAYER_B")
self.assertEqual(layer_a["color"], "#ffff00")
self.assertEqual(layer_b["color"], "#0000ff")
def test_dxf_layout_geojson_with_all_layers(self):
self.client.force_login(self.user)
url = reverse("assets:dxf_layout_geojson", args=[self.intersection.id])
# All layers (default)
response = self.client.get(url, {"file": self.dxf_filename, "layers": "all"})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("features", data)
features = data["features"]
# Should have features for both LAYER_A and LAYER_B
feature_layers = [f["properties"]["Layer"] for f in features]
self.assertIn("LAYER_A", feature_layers)
self.assertIn("LAYER_B", feature_layers)
def test_dxf_layout_geojson_with_subset_layers(self):
self.client.force_login(self.user)
url = reverse("assets:dxf_layout_geojson", args=[self.intersection.id])
# Subset: only LAYER_A
response = self.client.get(url, {"file": self.dxf_filename, "layers": "LAYER_A"})
self.assertEqual(response.status_code, 200)
data = response.json()
features = data["features"]
feature_layers = [f["properties"]["Layer"] for f in features]
self.assertIn("LAYER_A", feature_layers)
self.assertNotIn("LAYER_B", feature_layers)
def test_dxf_layout_geojson_with_no_layers(self):
self.client.force_login(self.user)
url = reverse("assets:dxf_layout_geojson", args=[self.intersection.id])
# None layers
response = self.client.get(url, {"file": self.dxf_filename, "layers": "none"})
self.assertEqual(response.status_code, 200)
data = response.json()
features = data["features"]
self.assertEqual(len(features), 0)
def test_import_dxf_existing_plan_selection_flow(self):
self.client.force_login(self.user)
# 1. First trigger layout caching for a subset of layers
url_layout = reverse("assets:dxf_layout_geojson", args=[self.intersection.id])
response_layout = self.client.get(url_layout, {"file": self.dxf_filename, "layers": "LAYER_A"})
self.assertEqual(response_layout.status_code, 200)
# 2. POST to import_dxf indicating we selected this existing file
url_import = reverse("assets:import_dxf", args=[self.intersection.id])
response_import = self.client.post(url_import, {"dxf_file": self.dxf_filename})
self.assertEqual(response_import.status_code, 200)
# 3. Verify context variables passed to template
self.assertTrue(response_import.context["is_existing_plan"])
self.assertTrue(response_import.context["has_cached_layout"])
self.assertIsNotNone(response_import.context["previous_selection"])
# previous_selection should decode back to ['LAYER_A']
import json
prev_selection = json.loads(response_import.context["previous_selection"])
self.assertEqual(prev_selection, ["LAYER_A"])
def test_layout_cache_overwritten_on_selection_change(self):
self.client.force_login(self.user)
import os
import json
from django.conf import settings
# 1. First request for LAYER_A only
url_layout = reverse("assets:dxf_layout_geojson", args=[self.intersection.id])
response_a = self.client.get(url_layout, {"file": self.dxf_filename, "layers": "LAYER_A"})
self.assertEqual(response_a.status_code, 200)
data_a = response_a.json()
self.assertEqual(data_a["selected_layers"], ["LAYER_A"])
# Ensure single cache file exists
geojson_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_layouts')
cache_path = os.path.join(geojson_dir, f"{self.dxf_filename.lower().replace('.dxf', '.layout.geojson')}")
self.assertTrue(os.path.exists(cache_path))
# 2. Verify cache contents
with open(cache_path, "r", encoding="utf-8") as f:
content_a = json.load(f)
self.assertEqual(content_a["selected_layers"], ["LAYER_A"])
# 3. Second request for LAYER_B only (different selection)
response_b = self.client.get(url_layout, {"file": self.dxf_filename, "layers": "LAYER_B"})
self.assertEqual(response_b.status_code, 200)
data_b = response_b.json()
self.assertEqual(data_b["selected_layers"], ["LAYER_B"])
# 4. Verify cache file was overwritten with new selection
with open(cache_path, "r", encoding="utf-8") as f:
content_b = json.load(f)
self.assertEqual(content_b["selected_layers"], ["LAYER_B"])
def test_import_dxf_document_association_and_filter(self):
import os
from assets.models import TrafficLightIntersectionDocument
from django.core.files.uploadedfile import SimpleUploadedFile
self.client.force_login(self.user)
# 1. Create a dummy DXF file upload associated with the intersection
dummy_dxf = SimpleUploadedFile("carrefour_plan.dxf", b"Dummy DXF content", content_type="application/dxf")
url_import = reverse("assets:import_dxf", args=[self.intersection.id])
response_post = self.client.post(url_import, {"dxf_file_upload": dummy_dxf})
# Verify document was created
dxf_docs = TrafficLightIntersectionDocument.objects.filter(intersection=self.intersection)
self.assertEqual(dxf_docs.count(), 1)
doc = dxf_docs.first()
self.assertTrue(doc.file.name.endswith(".dxf"))
# 2. Get request to import_dxf selection step
response_get = self.client.get(url_import)
self.assertEqual(response_get.status_code, 200)
# Verify the file is listed in the dxf_files list context
self.assertIn(os.path.basename(doc.file.name), response_get.context["dxf_files"])
# Clean up database document file
try:
doc.file.delete()
doc.delete()
except Exception:
pass
class TrafficLightRadarRelationTests(TestCase):
def setUp(self):
from django.contrib.gis.geos import Point
from assets.models import TrafficLightIntersection, TrafficLightRadarModel, TrafficLightRadarPoleModel, TrafficLightRadarPole, TrafficLightPole, TrafficLightPoleModel
self.intersection = TrafficLightIntersection.objects.create(
code="INT-TEST",
name_fr="Carrefour Test",
geom=Point(4.35, 50.85, srid=4326)
)
# Transform intersection geom to 3812 as done in DB
self.intersection.geom.transform(3812)
self.intersection.save()
self.radar_model = TrafficLightRadarModel.objects.create(
code="RM-001",
name_fr="Modèle Radar Test"
)
self.pole_model = TrafficLightRadarPoleModel.objects.create(
code="PM-001",
name_fr="Modèle Poteau Radar Test"
)
self.tl_pole_model = TrafficLightPoleModel.objects.create(
code="TPM-001",
name_fr="Modèle Poteau Tricolore Test"
)
def test_radar_geom_alignment_with_support(self):
from django.contrib.gis.geos import Point
from assets.models import TrafficLightRadar, TrafficLightRadarPole
# 1. Create a support pole with specific geometry
support_geom = Point(4.351, 50.851, srid=4326)
support_geom.transform(3812)
pole = TrafficLightRadarPole.objects.create(
code="POLE-001",
intersection=self.intersection,
model=self.pole_model,
geom=support_geom
)
# 2. Create a radar initially at a different position
initial_geom = Point(4.35, 50.85, srid=4326)
initial_geom.transform(3812)
radar = TrafficLightRadar.objects.create(
code="RADAR-001",
intersection=self.intersection,
model=self.radar_model,
geom=initial_geom
)
self.assertNotEqual(radar.geom, support_geom)
# 3. Link the radar to the support pole and save
radar.pole = pole
radar.save()
# 4. Verify position was aligned to support pole
self.assertEqual(radar.geom, support_geom)
def test_radar_connection_lines_geojson_helper(self):
from django.contrib.gis.geos import Point
from assets.models import TrafficLightRadar, TrafficLightRadarPole, TrafficLightPole
from assets.views.core import get_connection_lines_geojson
# 1. Create support pole
pole_geom = Point(4.351, 50.851, srid=4326)
pole_geom.transform(3812)
pole = TrafficLightRadarPole.objects.create(
code="POLE-001",
intersection=self.intersection,
model=self.pole_model,
geom=pole_geom
)
# 2. Create power connection pole (trafficlights_pole) at a different location
power_geom = Point(4.352, 50.852, srid=4326)
power_geom.transform(3812)
tl_pole = TrafficLightPole.objects.create(
code="TLPOLE-001",
intersection=self.intersection,
model=self.tl_pole_model,
geom=power_geom
)
# 3. Create radar linked to both
radar = TrafficLightRadar.objects.create(
code="RADAR-001",
intersection=self.intersection,
model=self.radar_model,
pole=pole,
power_trafficlights_pole=tl_pole,
geom=pole_geom
)
# 4. Call helper
geojson = get_connection_lines_geojson(radar)
self.assertIsNotNone(geojson)
self.assertEqual(geojson["type"], "FeatureCollection")
self.assertEqual(len(geojson["features"]), 1)
feat = geojson["features"][0]
self.assertEqual(feat["properties"]["relation_type"], "electrical_connection")
self.assertEqual(feat["properties"]["parent_code"], "TLPOLE-001")
# Coordinates should be in EPSG:4326 (WGS84)
expected_p1 = pole_geom.clone()
expected_p1.transform(4326)
expected_p2 = power_geom.clone()
expected_p2.transform(4326)
coords = feat["geometry"]["coordinates"]
self.assertAlmostEqual(coords[0][0], expected_p1.x, places=5)
self.assertAlmostEqual(coords[0][1], expected_p1.y, places=5)
self.assertAlmostEqual(coords[1][0], expected_p2.x, places=5)
self.assertAlmostEqual(coords[1][1], expected_p2.y, places=5)
class TrafficLightPoleSignPanelsTest(TestCase):
def setUp(self):
from assets.models import TrafficLightIntersection, TrafficLightPoleModel, TrafficLightPole
from assets.models.sign import SignPanelModel
from common.models import Thematic
self.thematic = Thematic.objects.get_or_create(code="trafficlights", defaults={"name_fr": "Trafic"})[0]
self.sign_thematic = Thematic.objects.get_or_create(code="sign", defaults={"name_fr": "Signalisation"})[0]
self.intersection = TrafficLightIntersection.objects.create(
code="INT-001",
name_fr="Test Intersection"
)
self.pole_model = TrafficLightPoleModel.objects.create(
code="TPM-001",
name_fr="Modèle Poteau Tricolore Test"
)
self.pole = TrafficLightPole.objects.create(
code="POLE-001",
intersection=self.intersection,
model=self.pole_model
)
self.panel_model = SignPanelModel.objects.create(
code="SPM-001",
name_fr="Modèle Panneau Test"
)
def test_sorted_signpanels(self):
from assets.models.sign import SignPanel
# Create sign panels attached to the traffic light pole
panel2 = SignPanel.objects.create(
code="PANEL-002",
model=self.panel_model,
trafficlight_pole=self.pole
)
panel1 = SignPanel.objects.create(
code="PANEL-001",
model=self.panel_model,
trafficlight_pole=self.pole
)
# Verify that sorted_signpanels returns them sorted by code
panels = list(self.pole.sorted_signpanels)
self.assertEqual(len(panels), 2)
self.assertEqual(panels[0], panel1)
self.assertEqual(panels[1], panel2)
def test_radar_pole_sorted_signpanels(self):
from assets.models import TrafficLightRadarPole, TrafficLightRadarPoleModel
radar_pole_model = TrafficLightRadarPoleModel.objects.create(
code="RPM-001",
name_fr="Modèle Radar Pole Test"
)
radar_pole = TrafficLightRadarPole.objects.create(
code="RPOLE-001",
intersection=self.intersection,
model=radar_pole_model
)
from assets.models.buildings import BuildingAsset
class PublicBuildingTestCase(TestCase):
def setUp(self):
# Complexe sportif (Localisation Racine)
self.complex = PublicBuilding.objects.create(
code="CMP-001",
name_fr="Complexe Sportif Municipal",
name_nl="Gemeentelijk Sportcomplex",
address="Rue du Sport 1, 1000 Bruxelles",
owner="Ville de Bruxelles",
usage_type="sports",
location_type="site",
building_status="in_service",
is_erp=True,
erp_category="1",
erp_type="X",
geom=MultiPolygon(Polygon(((0, 0), (0, 10), (10, 10), (10, 0), (0, 0)))),
)
# Bloc A (Localisation Enfant)
self.bloc_a = PublicBuilding.objects.create(
code="BLOC-A",
name_fr="Bloc A",
parent_location=self.complex,
location_type="building",
)
# Étage 0 (Localisation Enfant du Bloc A)
self.etage_0 = PublicBuilding.objects.create(
code="ETG-0",
name_fr="Étage 0",
parent_location=self.bloc_a,
location_type="level",
level_number=0,
)
def test_public_building_location_hierarchy(self):
self.assertEqual(self.complex.code, "CMP-001")
self.assertEqual(self.complex.location_type, "site")
self.assertEqual(self.bloc_a.parent_location, self.complex)
self.assertEqual(self.etage_0.parent_location, self.bloc_a)
self.assertIn(self.bloc_a, self.complex.get_child_locations())
def test_building_asset_tree_with_civil_engineering(self):
# Génie civil / Bâti A (Asset Racine)
civil_asset = BuildingAsset.objects.create(
building=self.complex,
location=self.bloc_a,
code="BATI-A",
name_fr="Bâti Bloc A",
asset_type="civil_engineering"
)
# Asset Enfant: Façade Nord
facade_asset = BuildingAsset.objects.create(
building=self.complex,
location=self.bloc_a,
parent_asset=civil_asset,
code="FAC-N",
name_fr="Façade Nord",
asset_type="facade"
)
# Asset Enfant: Chaudière
hvac_asset = BuildingAsset.objects.create(
building=self.complex,
location=self.etage_0,
parent_asset=civil_asset,
code="CVC-01",
name_fr="Chaudière",
asset_type="hvac"
)
self.assertEqual(civil_asset.asset_type, "civil_engineering")
self.assertEqual(facade_asset.parent_asset, civil_asset)
self.assertEqual(hvac_asset.parent_asset, civil_asset)
self.assertIn(facade_asset, civil_asset.get_child_assets())
self.assertIn(hvac_asset, civil_asset.get_child_assets())
class PublicBuildingEditViewTest(TestCase):
def setUp(self):
self.thematic = Thematic.objects.create(
code="publicbuildings",
name_fr="Bâtiments Publics",
name_nl="Openbare Gebouwen"
)
self.building = PublicBuilding.objects.create(
code="BAT-999",
name_fr="Bâtiment de Test",
owner="Commune Test"
)
# User without edit permissions
self.viewer_user = User.objects.create_user(username="viewer", password="password123")
self.viewer_config = UserConfig.objects.create(user=self.viewer_user)
UserThematics.objects.create(
user_config=self.viewer_config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=False
)
# User with edit permissions
self.editor_user = User.objects.create_user(username="editor", password="password123")
self.editor_config = UserConfig.objects.create(user=self.editor_user)
UserThematics.objects.create(
user_config=self.editor_config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True
)
def test_publicbuilding_edit_permission_denied_for_viewer(self):
self.client.login(username="viewer", password="password123")
url = reverse('assets:publicbuilding_edit', kwargs={'pk': self.building.pk})
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_publicbuilding_edit_allowed_for_editor(self):
self.client.login(username="editor", password="password123")
url = reverse('assets:publicbuilding_edit', kwargs={'pk': self.building.pk})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "BAT-999")
def test_publicbuilding_edit_post_updates_building(self):
self.client.login(username="editor", password="password123")
url = reverse('assets:publicbuilding_edit', kwargs={'pk': self.building.pk})
post_data = {
'code': 'BAT-999',
'location_type': 'building',
'name_fr': 'Bâtiment Modifié',
'name_nl': 'Gewijzigd Gebouw',
'address': 'Rue du Test 123',
'owner': 'Nouveau Propriétaire',
'usage_type': 'administrative',
'building_status': 'in_service',
'is_erp': True,
}
response = self.client.post(url, post_data)
self.assertRedirects(response, reverse('assets:publicbuilding_detail', kwargs={'pk': self.building.pk}))
self.building.refresh_from_db()
self.assertEqual(self.building.name_fr, 'Bâtiment Modifié')
self.assertEqual(self.building.address, 'Rue du Test 123')
self.assertEqual(self.building.owner, 'Nouveau Propriétaire')
class GeoSyncMixinTest(TestCase):
def setUp(self):
from common.models import Municipality
from assets.models import PublicLightingStreet, PublicLightingPoleModel, PublicLightingPole, ITSLocation
self.municipality = Municipality.objects.create(name_fr="Bruxelles", name_nl="Brussel", fid="1000")
self.street = PublicLightingStreet.objects.create(
code="STREET-001",
municipality=self.municipality,
)
self.pole_model = PublicLightingPoleModel.objects.create(
code="POLEMODEL-001",
name_fr="Modèle Poteau",
)
self.pole_class = PublicLightingPole
self.its_location_class = ITSLocation
def test_update_lon_lat_updates_geojson_and_geom(self):
pole = self.pole_class.objects.create(
code="PLPOLE-SYNC-1",
street=self.street,
model=self.pole_model,
lon=4.3520,
lat=50.8475,
)
self.assertIsNotNone(pole.geojson)
self.assertIn('"type": "Point"', pole.geojson)
self.assertIsNotNone(pole.geom)
self.assertEqual(pole.geom.srid, 3812)
def test_update_geom_updates_geojson_and_coords(self):
from django.contrib.gis.geos import Point
pt_3812 = Point(648819, 670788, srid=3812)
pole = self.pole_class.objects.create(
code="PLPOLE-SYNC-2",
street=self.street,
model=self.pole_model,
geom=pt_3812,
)
self.assertIsNotNone(pole.geojson)
self.assertIsNotNone(pole.lon)
self.assertIsNotNone(pole.lat)
self.assertAlmostEqual(pole.lon, 4.35, places=1)
self.assertAlmostEqual(pole.lat, 50.85, places=1)
def test_polygon_concave_uses_point_on_surface(self):
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
# U-shaped polygon in WGS84 transformed to 3812
u_poly_wgs84 = GEOSGeometry('POLYGON ((4.3500 50.8500, 4.3500 50.8510, 4.3510 50.8510, 4.3510 50.8508, 4.3502 50.8508, 4.3502 50.8500, 4.3500 50.8500))', srid=4326)
u_poly_3812 = MultiPolygon(u_poly_wgs84.transform(3812, clone=True))
location = self.its_location_class.objects.create(
code="ITSLOC-SYNC-1",
geom=u_poly_3812,
)
self.assertIsNotNone(location.geojson)
self.assertIsNotNone(location.lon)
self.assertIsNotNone(location.lat)
point_result = Point(location.lon, location.lat, srid=4326)
self.assertTrue(u_poly_wgs84.intersects(point_result) or u_poly_wgs84.contains(point_result))
def test_update_fields_includes_spatial_changes(self):
pole = self.pole_class.objects.create(
code="PLPOLE-SYNC-3",
street=self.street,
model=self.pole_model,
lon=4.3520,
lat=50.8475,
)
pole.lon = 4.3600
pole.save(update_fields=['lon'])
pole.refresh_from_db()
self.assertAlmostEqual(pole.lon, 4.3600, places=4)
self.assertIn('4.36', pole.geojson)
class SyncGeoJsonCommandTest(TestCase):
def test_sync_all_geojson_command(self):
from io import StringIO
from django.core.management import call_command
from django.contrib.gis.geos import Point
from assets.models.publiclighting import PublicLightingPole, PublicLightingPoleModel, PublicLightingStreet
street = PublicLightingStreet.objects.create(code="ST-TEST", name_fr="Rue Test")
pole_model = PublicLightingPoleModel.objects.create(code="PM-TEST", name_fr="Model Test")
pt = Point(4.3517, 50.8467, srid=4326).transform(3812, clone=True)
pole = PublicLightingPole.objects.create(
code="TEST-SYNC-GEOJSON",
street=street,
model=pole_model,
geom=pt,
)
PublicLightingPole.objects.filter(id=pole.id).update(geojson=None)
pole.refresh_from_db()
self.assertIsNone(pole.geojson)
out = StringIO()
call_command("sync_all_geojson", "--dry-run", stdout=out)
self.assertIn("[DRY-RUN] Would update", out.getvalue())
pole.refresh_from_db()
self.assertIsNone(pole.geojson)
out = StringIO()
call_command("sync_all_geojson", stdout=out)
self.assertIn("Successfully updated", out.getvalue())
pole.refresh_from_db()
self.assertIsNotNone(pole.geojson)
self.assertIn("4.3517", pole.geojson)
class ParentChildAssetLocationAndGeoSyncTest(TestCase):
def setUp(self):
from django.contrib.gis.geos import Point
from assets.models.trafficlights import (
TrafficLightIntersection,
TrafficLightPole,
TrafficLightPoleModel,
TrafficLightLantern,
TrafficLightLanternModel,
)
from assets.models.sign import SignStreet, SignPole, SignPoleModel, SignPanel
from assets.models.publiclighting import (
PublicLightingStreet,
PublicLightingPole,
PublicLightingPoleModel,
PublicLightingLightFixture,
PublicLightingLightFixtureModel,
)
self.point_3812 = Point(649529.6, 669829.8, srid=3812)
# Traffic light setup
self.intersection = TrafficLightIntersection.objects.create(
code="TL-INT-01",
name_fr="Carrefour Test",
geom=self.point_3812
)
self.tl_pole_model = TrafficLightPoleModel.objects.create(code="TLPM-01", name_fr="Modèle Poteau TL")
self.tl_pole = TrafficLightPole.objects.create(
code="TL-POL-01",
intersection=self.intersection,
model=self.tl_pole_model,
geom=self.point_3812
)
self.lantern_model = TrafficLightLanternModel.objects.create(
code="LAN-MOD-01",
name_fr="Modèle Lanterne",
voltage=230
)
# Sign setup
self.sign_street = SignStreet.objects.create(code="STR-01", name_fr="Rue du Test")
self.sign_pole_model = SignPoleModel.objects.create(code="SPM-01", name_fr="Modèle Poteau Sign")
self.sign_pole = SignPole.objects.create(
code="SP-01",
street=self.sign_street,
model=self.sign_pole_model,
geom=self.point_3812
)
# Public lighting setup
self.pl_street = PublicLightingStreet.objects.create(code="PL-STR-01", name_fr="Rue Eclairage")
self.pl_pole_model = PublicLightingPoleModel.objects.create(code="PLPM-01", name_fr="Modèle Poteau PL")
self.pl_pole = PublicLightingPole.objects.create(
code="PL-POL-01",
street=self.pl_street,
model=self.pl_pole_model,
geom=self.point_3812
)
self.fixture_model = PublicLightingLightFixtureModel.objects.create(code="LFM-01", name_fr="Modèle Luminaire")
def test_lantern_inherits_coordinates_and_location_from_pole(self):
from assets.models.trafficlights import TrafficLightLantern
lantern = TrafficLightLantern.objects.create(
code="TL-LAN-01",
pole=self.tl_pole,
model=self.lantern_model
)
lantern.refresh_from_db()
self.assertIsNotNone(lantern.geom)
self.assertEqual(lantern.geom, self.tl_pole.geom)
self.assertIsNotNone(lantern.lon)
self.assertIsNotNone(lantern.lat)
self.assertIsNotNone(lantern.geojson)
self.assertEqual(lantern.intersection, self.intersection)
self.assertEqual(lantern.get_location(), self.intersection)
self.assertEqual(lantern.get_parent_support(), self.tl_pole)
def test_signpanel_attached_to_trafficlight_pole_inherits_coords(self):
from assets.models.sign import SignPanel
panel = SignPanel.objects.create(
code="PANEL-01",
trafficlight_pole=self.tl_pole
)
panel.refresh_from_db()
self.assertIsNotNone(panel.geom)
self.assertEqual(panel.geom, self.tl_pole.geom)
self.assertEqual(panel.get_parent_support(), self.tl_pole)
self.assertEqual(panel.get_location(), self.intersection)
def test_publiclighting_fixture_inherits_coords_and_street(self):
from assets.models.publiclighting import PublicLightingLightFixture
fixture = PublicLightingLightFixture.objects.create(
code="FIXTURE-01",
pole=self.pl_pole,
street=self.pl_street,
model=self.fixture_model
)
fixture.refresh_from_db()
self.assertIsNotNone(fixture.geom)
self.assertEqual(fixture.geom, self.pl_pole.geom)
self.assertEqual(fixture.get_parent_support(), self.pl_pole)
self.assertEqual(fixture.get_location(), self.pl_street)
def test_pole_save_cascades_to_children(self):
from django.contrib.gis.geos import Point
from assets.models.trafficlights import TrafficLightLantern
lantern = TrafficLightLantern.objects.create(
code="TL-LAN-02",
pole=self.tl_pole,
model=self.lantern_model
)
new_point = Point(650000.0, 670000.0, srid=3812)
self.tl_pole.geom = new_point
self.tl_pole.save()
lantern.refresh_from_db()
self.assertEqual(lantern.geom, new_point)
class CreateInterventionForAssetWorkflowTest(TestCase):
def setUp(self):
from django.contrib.auth.models import User, Permission
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.geos import Point
from common.models import Thematic, UserThematics, UserConfig
from contracts.models import Contract, Company
from interventions.models import (
Intervention, InterventionAsset, InterventionLocation,
InterventionTimeLine, InterventionPlanificationTimeLine, InterventionNote,
Symptom, DirectContractRouting
)
from assets.models import (
AssetCategory,
PublicLightingStreet, PublicLightingPole, PublicLightingPoleModel,
TrafficLightIntersection, TrafficLightContract, TrafficLightPole, TrafficLightPoleModel,
Structure, StructureContract, StructureLocation
)
self.thematic_pl, _ = Thematic.objects.get_or_create(code="publiclighting", defaults={'name_fr': "Éclairage public"})
self.thematic_tl, _ = Thematic.objects.get_or_create(code="trafficlights", defaults={'name_fr': "Signalisation lumineuse"})
self.thematic_struct, _ = Thematic.objects.get_or_create(code="structures", defaults={'name_fr': "Ouvrages d'art"})
self.user = User.objects.create_user(username="asset_agent", password="secret_password", email="agent@example.com")
self.user.is_staff = True
self.user.save()
# Donner la permission add_intervention
perm = Permission.objects.get(codename="add_intervention")
self.user.user_permissions.add(perm)
# Configurer UserConfig et thématiques
self.user_config = UserConfig.objects.create(
user=self.user,
is_intern=True,
limit_interventions_to_contracts=False
)
UserThematics.objects.create(user_config=self.user_config, thematic=self.thematic_pl, can_edit_interventions=True)
UserThematics.objects.create(user_config=self.user_config, thematic=self.thematic_tl, can_edit_interventions=True)
UserThematics.objects.create(user_config=self.user_config, thematic=self.thematic_struct, can_edit_interventions=True)
import datetime
# Prestataires et contrats
self.company_a = Company.objects.create(name="Company Alpha")
self.company_b = Company.objects.create(name="Company Beta")
self.contract_a = Contract.objects.create(
company=self.company_a,
contract_number="CTR-PL-001",
start_date=datetime.date(2025, 1, 1),
end_date=datetime.date(2030, 1, 1),
description="Contrat PL Alpha"
)
self.contract_tl = Contract.objects.create(
company=self.company_b,
contract_number="CTR-TL-001",
start_date=datetime.date(2025, 1, 1),
end_date=datetime.date(2030, 1, 1),
description="Contrat TL Curatif"
)
# Assets et localisations
self.pt_pl = Point(648819, 670788, srid=3812)
self.pl_category = AssetCategory.objects.create(name_fr="Support PL", code="CAT-PL-1", thematic=self.thematic_pl)
self.pl_street = PublicLightingStreet.objects.create(code="ST-PL-1", name_fr="Rue Royale", geom=MultiPolygon(self.pt_pl.buffer(5)))
self.pl_model = PublicLightingPoleModel.objects.create(code="PLM-1", name_fr="Candélabre 8m", category=self.pl_category)
self.pl_pole = PublicLightingPole.objects.create(
code="POLE-PL-001",
street=self.pl_street,
model=self.pl_model,
geom=self.pt_pl
)
self.client.login(username="asset_agent", password="secret_password")
def test_create_intervention_full_workflow_from_asset(self):
from interventions.models import Symptom, Intervention, InterventionTimeLine, InterventionPlanificationTimeLine, InterventionNote
from django.urls import reverse
symptom = Symptom.objects.create(
thematic=self.thematic_pl,
name_fr="Candélabre accidenté",
priority="1",
origin_type="damage",
contract=self.contract_a,
is_active=True
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'publiclightingpole',
'asset_id': self.pl_pole.pk
})
response = self.client.post(url, {
'title': '', # vide, doit être rempli automatiquement par le symptôme
'symptom_id': symptom.id,
'maintain_type': 'corrective',
'init_description': 'Candélabre heurté par un camion hier soir.',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention_id = data.get('intervention_id')
self.assertIsNotNone(intervention_id)
intervention = Intervention.objects.get(pk=intervention_id)
# 1. Titre déduit du symptôme
self.assertEqual(intervention.title, "Candélabre accidenté")
# 2. Prestataire et contrat résolus depuis le symptôme
self.assertEqual(intervention.contract, self.contract_a)
self.assertEqual(intervention.assigned_provider, self.company_a)
# 3. Géométrie et localisation créées depuis l'asset
self.assertIsNotNone(intervention.geom)
self.assertEqual(intervention.geom.srid, 3812)
self.assertEqual(intervention.geom.geom_type, 'MultiPolygon')
self.assertIsNotNone(intervention.lon)
self.assertIsNotNone(intervention.lat)
self.assertEqual(intervention.location_code, "ST-PL-1")
self.assertEqual(intervention.address, "Rue Royale")
# 4. Type, catégorie, priorité et type d'origine remplis automatiquement
self.assertEqual(intervention.type, 'fixing')
self.assertEqual(intervention.origin_type, 'damage')
self.assertEqual(intervention.priority, '1')
self.assertEqual(intervention.asset_category, self.pl_category)
# 5. Date de début prévue (+2h) & timeline de planification
self.assertIsNotNone(intervention.expected_begin_time)
plan_event = InterventionPlanificationTimeLine.objects.filter(
intervention=intervention,
event_type='expected_begin'
).first()
self.assertIsNotNone(plan_event)
self.assertEqual(plan_event.event_user, self.user)
# 6. Historique de création (Timeline) & Origine
timeline_event = InterventionTimeLine.objects.filter(
intervention=intervention,
event_type='creation'
).first()
self.assertIsNotNone(timeline_event)
self.assertEqual(timeline_event.event_user, self.user)
self.assertEqual(timeline_event.to_status, 'in_preparation')
# 7. Note d'origine créée
origin_note = InterventionNote.objects.filter(
intervention=intervention,
note_type=InterventionNote.NoteType.ORIGIN
).first()
self.assertIsNotNone(origin_note)
self.assertEqual(origin_note.content, 'Candélabre heurté par un camion hier soir.')
self.assertEqual(origin_note.note_author, self.user)
def test_create_intervention_trafficlight_contract_resolution(self):
from assets.models.trafficlights import TrafficLightIntersection, TrafficLightPole, TrafficLightPoleModel, TrafficLightContract
from interventions.models import Symptom, Intervention
from django.urls import reverse
from django.contrib.gis.geos import Point
intersection = TrafficLightIntersection.objects.create(
code="TL-INT-100",
name_fr="Carrefour Arts-Loi",
geom=Point(649000, 671000, srid=3812)
)
tl_pole_model = TrafficLightPoleModel.objects.create(code="TLPM-100", name_fr="Modèle Poteau TL")
tl_pole = TrafficLightPole.objects.create(
code="TL-POL-100",
intersection=intersection,
model=tl_pole_model,
geom=Point(649000, 671000, srid=3812)
)
tl_contract_rel = TrafficLightContract.objects.create(
intersection=intersection,
contract=self.contract_tl,
maintenance_type='corrective',
status='active'
)
symptom_tl = Symptom.objects.create(
thematic=self.thematic_tl,
name_fr="Feu éteint",
maintenance_type='corrective',
is_active=True
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'trafficlightpole',
'asset_id': tl_pole.pk
})
response = self.client.post(url, {
'title': 'Panne de signalisation',
'symptom_id': symptom_tl.id,
'maintain_type': 'corrective',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention = Intervention.objects.get(pk=data['intervention_id'])
self.assertEqual(intervention.contract, self.contract_tl)
self.assertEqual(intervention.assigned_provider, self.company_b)
self.assertEqual(intervention.thematic, self.thematic_tl)
def test_create_intervention_structure_direct_contract_routing(self):
from assets.models.structures import Structure, StructureLocation
from contracts.models import Contract
from interventions.models import Symptom, Intervention, DirectContractRouting
from django.urls import reverse
from django.contrib.gis.geos import Point, MultiPolygon
import datetime
struct_loc = StructureLocation.objects.create(
code="OA-001",
name_fr="Pont de Buda",
geom=MultiPolygon(Point(650000, 672000, srid=3812).buffer(10))
)
structure = Structure.objects.create(
code="STR-OA-001",
name_fr="Pont de Buda",
main_location=struct_loc
)
contract_struct = Contract.objects.create(
company=self.company_a,
contract_number="CTR-STRUCT-001",
start_date=datetime.date(2025, 1, 1),
end_date=datetime.date(2030, 1, 1),
description="Contrat Ouvrages d'Art"
)
symptom_struct = Symptom.objects.create(
thematic=self.thematic_struct,
name_fr="Fissure tablier",
is_active=True
)
DirectContractRouting.objects.create(
thematic='structures',
asset_code="STR-OA-001",
symptom=symptom_struct,
contract=contract_struct
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'structure',
'asset_id': structure.pk
})
response = self.client.post(url, {
'title': "Inspection fissure",
'symptom_id': symptom_struct.id,
'maintain_type': 'corrective',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention = Intervention.objects.get(pk=data['intervention_id'])
self.assertEqual(intervention.contract, contract_struct)
self.assertEqual(intervention.assigned_provider, self.company_a)
self.assertEqual(intervention.thematic, self.thematic_struct)
def test_create_intervention_user_default_contract_fallback(self):
from interventions.models import Symptom, Intervention
from django.urls import reverse
self.user_config.limit_interventions_to_contracts = True
self.user_config.default_contract = self.contract_a
self.user_config.save()
symptom = Symptom.objects.create(
thematic=self.thematic_pl,
name_fr="Ampoule grillée",
is_active=True
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'publiclightingpole',
'asset_id': self.pl_pole.pk
})
response = self.client.post(url, {
'title': "Ampoule HS",
'symptom_id': symptom.id,
'maintain_type': 'corrective',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention = Intervention.objects.get(pk=data['intervention_id'])
self.assertEqual(intervention.contract, self.contract_a)
self.assertEqual(intervention.assigned_provider, self.company_a)
def test_create_intervention_external_user_forces_to_be_approved(self):
from interventions.models import Symptom, Intervention
from django.urls import reverse
# Utilisateur externe sans accès au contrat du symptôme
self.user_config.is_intern = False
self.user_config.limit_interventions_to_contracts = True
self.user_config.save()
symptom = Symptom.objects.create(
thematic=self.thematic_pl,
name_fr="Candélabre endommagé",
contract=self.contract_a,
is_active=True
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'publiclightingpole',
'asset_id': self.pl_pole.pk
})
response = self.client.post(url, {
'title': "Candélabre endommagé",
'symptom_id': symptom.id,
'maintain_type': 'corrective',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention = Intervention.objects.get(pk=data['intervention_id'])
self.assertEqual(intervention.status, 'to_be_approved')
# Vérifier que le premier événement de timeline reflète to_be_approved
first_event = intervention.events.first()
self.assertEqual(first_event.to_status, 'to_be_approved')
self.assertEqual(first_event.event_user, self.user)
class TrafficLightIntersectionControllerModelTest(TestCase):
def setUp(self):
self.thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={
"name_fr": "Feux de signalisation",
"name_nl": "Verkeerslichten"
}
)
self.user, _ = User.objects.get_or_create(username="tl_ctrl_testuser")
self.user.set_password("password123")
self.user.save()
self.config, _ = UserConfig.objects.get_or_create(user=self.user, defaults={"limit_assets_to_contracts": False})
self.config.limit_assets_to_contracts = False
self.config.save()
UserThematics.objects.get_or_create(
user_config=self.config,
thematic=self.thematic,
defaults={"can_view_assets": True}
)
from django.contrib.gis.geos import Point
geom_intersection = Point(648000, 670000, srid=3812)
self.intersection = TrafficLightIntersection.objects.create(
code="TL_INT_001",
name_fr="Carrefour Test",
geom=geom_intersection,
lon=4.3508,
lat=50.8465,
current_index="IDX-12345"
)
self.category, _ = AssetCategory.objects.get_or_create(
code="TL_CONTROLLER_CAT",
defaults={
"name_fr": "Contrôleur de trafic",
"name_nl": "Verkeersregelaar",
"thematic": self.thematic
}
)
from assets.models import TrafficLightControllerModel, TrafficLightController
self.controller_model = TrafficLightControllerModel.objects.create(
code="CTRL_MOD_01",
name_fr="Siemens Sitraffic C1000",
name_nl="Siemens Sitraffic C1000 NL",
category=self.category
)
self.controller = TrafficLightController.objects.create(
code="CTRL_001",
intersection=self.intersection,
model=self.controller_model,
geom=geom_intersection,
lon=4.3508,
lat=50.8465,
status="active"
)
def test_controller_model_name_property(self):
self.assertEqual(self.intersection.controller_model_name, "Siemens Sitraffic C1000")
def test_controller_model_name_none_when_no_controller(self):
from django.contrib.gis.geos import Point
empty_intersection = TrafficLightIntersection.objects.create(
code="TL_INT_EMPTY",
name_fr="Carrefour Sans Contrôleur",
geom=Point(648000, 670000, srid=3812)
)
self.assertIsNone(empty_intersection.controller_model_name)
def test_detail_view_renders_controller_model_name(self):
self.client.force_login(self.user)
# Test French
response_fr = self.client.get(
reverse("assets:trafficlights_intersections_detail", args=[self.intersection.id]),
HTTP_HOST="localhost",
HTTP_ACCEPT_LANGUAGE="fr"
)
self.assertEqual(response_fr.status_code, 200)
content_fr = response_fr.content.decode("utf-8")
self.assertIn("Modèle du contrôleur", content_fr)
self.assertIn("Siemens Sitraffic C1000", content_fr)
self.assertIn("IDX-12345", content_fr)
# Test Dutch
response_nl = self.client.get(
reverse("assets:trafficlights_intersections_detail", args=[self.intersection.id]),
HTTP_HOST="localhost",
HTTP_ACCEPT_LANGUAGE="nl"
)
self.assertEqual(response_nl.status_code, 200)
content_nl = response_nl.content.decode("utf-8")
self.assertIn("Controllermodel", content_nl)
self.assertIn("Siemens Sitraffic C1000 NL", content_nl)
class TrafficLightProgrammingTest(TestCase):
def setUp(self):
from django.contrib.gis.geos import Point
from django.contrib.contenttypes.models import ContentType
from assets.models.trafficlights import TrafficLightProgramming
self.thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={
"name_fr": "Feux de signalisation",
"name_nl": "Verkeerslichten"
}
)
from common.models import Role
self.user = User.objects.create_user(username="proguser", password="password123")
self.config = UserConfig.objects.create(user=self.user, limit_assets_to_contracts=False)
admin_role, _ = Role.objects.get_or_create(name="admin")
self.config.roles.add(admin_role)
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True
)
self.category, _ = AssetCategory.objects.get_or_create(
code="TL_PROGRAMMING",
defaults={
"name_fr": "Programmation",
"name_nl": "Programmatie",
"thematic": self.thematic
}
)
prog_ct = ContentType.objects.get_for_model(TrafficLightProgramming)
self.category.allowed_models.add(prog_ct)
self.intersection = TrafficLightIntersection.objects.create(
code="K999",
name_fr="Carrefour Test Prog",
geom=Point(648000, 670000, srid=3812),
lon=4.3508,
lat=50.8465,
current_index="01"
)
def test_programming_category_exists_and_linked(self):
from assets.models.trafficlights import TrafficLightProgramming
prog_ct = ContentType.objects.get_for_model(TrafficLightProgramming)
self.assertTrue(self.category.allowed_models.filter(id=prog_ct.id).exists())
def test_intersection_get_assets_includes_programmations(self):
from assets.models.trafficlights import TrafficLightProgramming
prog_active = TrafficLightProgramming.objects.create(
code="K999-PRG-001",
intersection=self.intersection,
status="active",
index="01",
plan="Plan A"
)
prog_inactive = TrafficLightProgramming.objects.create(
code="K999-PRG-002",
intersection=self.intersection,
status="removed",
index="00",
plan="Plan Old"
)
active_assets = self.intersection.get_active_assets()
all_assets = self.intersection.get_all_assets()
self.assertIn("programmations", active_assets)
self.assertIn("programmations", all_assets)
self.assertEqual(len(active_assets["programmations"]), 1)
self.assertEqual(active_assets["programmations"][0].id, prog_active.id)
self.assertEqual(len(all_assets["programmations"]), 2)
def test_create_programming_via_dedicated_endpoint(self):
from assets.models.trafficlights import TrafficLightProgramming
from django.core.files.uploadedfile import SimpleUploadedFile
self.client.force_login(self.user)
test_file = SimpleUploadedFile("prog_test.txt", b"programming binary data", content_type="text/plain")
response = self.client.post(
reverse("assets:trafficlights_add_programming", kwargs={"intersection_id": self.intersection.id}),
{
"code": "K999-PRG-NEW",
"index": "02",
"plan": "Plan 2B",
"commissioning_date": "2026-05-01",
"uncommissioning_date": "2026-12-31",
"status": "active",
"info": "Notes sur la programmation",
"file": test_file
},
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
self.assertEqual(data["asset_code"], "K999-PRG-NEW")
prog = TrafficLightProgramming.objects.get(code="K999-PRG-NEW")
self.assertEqual(prog.intersection, self.intersection)
self.assertEqual(prog.index, "02")
self.assertEqual(prog.plan, "Plan 2B")
self.assertEqual(prog.info, "Notes sur la programmation")
from django.utils import timezone
self.assertEqual(timezone.localtime(prog.commissioning_date).strftime("%Y-%m-%d"), "2026-05-01")
self.assertEqual(timezone.localtime(prog.uncommissioning_date).strftime("%Y-%m-%d"), "2026-12-31")
# Check document attached
doc = prog.documents.first()
self.assertIsNotNone(doc)
self.assertEqual(doc.document_type, "programming_file")
def test_create_programming_via_create_asset(self):
from assets.models.trafficlights import TrafficLightProgramming
self.client.force_login(self.user)
response = self.client.post(
f"/assets/trafficlights/trafficlightintersection/{self.intersection.id}/create-asset/",
data=json.dumps({
"category_id": self.category.id,
"asset_type": "trafficlightprogramming",
"data": {}
}),
content_type="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
self.assertEqual(data["asset_type"], "trafficlightprogramming")
self.assertTrue(data["asset_code"].startswith("K999-PRG-"))
created_prog = TrafficLightProgramming.objects.get(id=data["asset_id"])
self.assertEqual(created_prog.intersection, self.intersection)
self.assertEqual(created_prog.category, self.category)
def test_create_programming_via_create_asset_api(self):
from assets.models.trafficlights import TrafficLightProgramming
self.client.force_login(self.user)
response = self.client.post(
reverse("assets:create_asset_api"),
data=json.dumps({
"thematic_code": "trafficlights",
"category_id": self.category.id,
"location_type": "trafficlightintersection",
"location_id": self.intersection.id,
"asset_type": "trafficlightprogramming",
"code": "K999-PRG-API",
"data": {
"index": "05",
"plan": "Plan API",
"info": "Created from API"
}
}),
content_type="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
self.assertEqual(data["asset_type"], "trafficlightprogramming")
prog = TrafficLightProgramming.objects.get(code="K999-PRG-API")
self.assertEqual(prog.intersection, self.intersection)
self.assertEqual(prog.index, "05")
self.assertEqual(prog.plan, "Plan API")
self.assertEqual(prog.info, "Created from API")
def test_update_programming_fields_via_api(self):
from assets.models.trafficlights import TrafficLightProgramming
from django.utils import timezone
prog = TrafficLightProgramming.objects.create(
code="K999-PRG-UPD",
intersection=self.intersection,
status="in_preparation",
index="01",
plan="Plan Init",
info="Initial Info"
)
self.client.force_login(self.user)
# Update index, plan, info, status, and dates
updates = {
"index": "02",
"plan": "Plan Updated",
"info": "Updated Info Notes",
"status": "active",
"commissioning_date": "2026-06-15",
"uncommissioning_date": "2027-01-01"
}
response = self.client.post(
reverse("assets:update_asset", kwargs={
"asset_model": "trafficlightprogramming",
"asset_id": prog.id
}),
data=json.dumps(updates),
content_type="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 200, f"Failed updating programming: {response.content}")
data = response.json()
self.assertTrue(data["success"])
prog.refresh_from_db()
self.assertEqual(prog.index, "02")
self.assertEqual(prog.plan, "Plan Updated")
self.assertEqual(prog.info, "Updated Info Notes")
self.assertEqual(prog.status, "active")
self.assertEqual(timezone.localtime(prog.commissioning_date).strftime("%Y-%m-%d"), "2026-06-15")
self.assertEqual(timezone.localtime(prog.uncommissioning_date).strftime("%Y-%m-%d"), "2027-01-01")
def test_programming_detail_view_renders_cms_fields(self):
from assets.models.trafficlights import TrafficLightProgramming
prog = TrafficLightProgramming.objects.create(
code="K999-PRG-VIEW",
intersection=self.intersection,
status="active",
index="03",
plan="Plan Detail",
info="Detailed Programming Info"
)
self.client.force_login(self.user)
response = self.client.get(
reverse("assets:trafficlights_assets_detail", kwargs={
"asset_model": "trafficlightprogramming",
"asset_id": prog.id
})
)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf-8")
self.assertIn("K999-PRG-VIEW", content)
self.assertIn("Plan Detail", content)
self.assertIn("Detailed Programming Info", content)
self.assertIn("K999", content)
def test_intersection_detail_view_renders_programmations_tab(self):
from assets.models.trafficlights import TrafficLightProgramming
prog = TrafficLightProgramming.objects.create(
code="K999-PRG-TAB",
intersection=self.intersection,
status="active",
index="01",
plan="Plan Tab"
)
self.client.force_login(self.user)
response = self.client.get(
reverse("assets:trafficlights_intersections_detail", kwargs={
"intersection_id": self.intersection.id
})
)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf-8")
self.assertIn("K999-PRG-TAB", content)
self.assertIn("assets-programmation", content)
self.assertIn("addProgrammingModal", content)
class ReplaceAssetStructureGeoAssetTest(TestCase):
def setUp(self):
from common.models import Thematic, UserConfig, UserThematics, Role
from assets.models.structures import Structure, StructureLocation, StructureGeoAsset, StructureAssetModel
from assets.models import AssetCategory
self.thematic = Thematic.objects.create(code="structures", name_fr="Ouvrages d'art")
self.user = User.objects.create_user(username="replace_user", password="password")
self.user_config = UserConfig.objects.create(user=self.user, is_intern=True)
self.admin_role, _ = Role.objects.get_or_create(name='admin')
self.user_config.roles.add(self.admin_role)
self.user_thematic = UserThematics.objects.create(
user_config=self.user_config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True,
)
self.struct = Structure.objects.create(code="WTB99", name_fr="Tunnel Test")
self.loc = StructureLocation.objects.create(code="WTB99A", name_fr="Location Test", structure=self.struct)
self.category = AssetCategory.objects.create(thematic=self.thematic, code="CAT_TEST", name_fr="Cat Test")
self.model = StructureAssetModel.objects.create(code="MOD_TEST", name_fr="Mod Test", category=self.category)
self.asset = StructureGeoAsset.objects.create(
code="WTB99A-000001",
name_fr="Asset Original",
structure=self.struct,
location=self.loc,
model=self.model,
category=self.category,
status="active"
)
def test_replace_asset_structuregeoasset_success(self):
from django.urls import reverse
from assets.models.structures import StructureGeoAsset
self.client.force_login(self.user)
url = reverse('assets:replace_asset', kwargs={'asset_model': 'structuregeoasset', 'asset_id': self.asset.id})
response = self.client.post(
url,
data=json.dumps({'copy_attributes': True}),
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
# Old asset should be archived and marked as removed
self.asset.refresh_from_db()
self.assertEqual(self.asset.status, 'removed')
self.assertIsNotNone(self.asset.replaced_by)
self.assertNotEqual(self.asset.code, "WTB99A-000001")
# New asset should have the original code and status active
new_asset = self.asset.replaced_by
self.assertEqual(new_asset.code, "WTB99A-000001")
self.assertEqual(new_asset.status, "active")
self.assertEqual(new_asset.structure, self.struct)
self.assertEqual(new_asset.location, self.loc)
self.assertEqual(new_asset.model, self.model)
self.assertEqual(new_asset.name_fr, "Asset Original")
class ExportAssetsTest(TestCase):
def setUp(self):
from django.contrib.auth import get_user_model
from common.models import Thematic, UserConfig, UserThematics
from assets.models import AssetCategory, Structure, StructureLocation, StructureAssetModel, StructureGeoAsset
self.user = get_user_model().objects.create_user(
username="test_export_user",
email="export_test@example.com",
password="testpassword",
is_staff=True,
)
self.user_config = UserConfig.objects.create(user=self.user)
self.thematic = Thematic.objects.get_or_create(code="structures", defaults={"name_fr": "Structures"})[0]
UserThematics.objects.create(
user_config=self.user_config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True,
)
self.struct = Structure.objects.create(code="EXP01", name_fr="Pont Export")
self.loc = StructureLocation.objects.create(code="EXP01A", name_fr="Loc Export", structure=self.struct)
self.category = AssetCategory.objects.create(thematic=self.thematic, code="CAT_EXP", name_fr="Cat Export")
self.model = StructureAssetModel.objects.create(code="MOD_EXP", name_fr="Mod Export", category=self.category)
self.asset = StructureGeoAsset.objects.create(
code="EXP01A-000001",
name_fr="Asset Pour Export",
structure=self.struct,
location=self.loc,
model=self.model,
category=self.category,
status="active"
)
def test_export_assets_csv_success(self):
from django.urls import reverse
self.client.force_login(self.user)
url = reverse('assets:export_assets')
ids_json = json.dumps([f"structuregeoasset:{self.asset.id}"])
response = self.client.post(url, {'ids': ids_json, 'format': 'csv'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/csv; charset=utf-8')
content = response.content.decode('utf-8')
self.assertIn("EXP01A-000001", content)
self.assertIn("Asset Pour Export", content)
from django.utils.translation import gettext as _
self.assertNotIn(_("Type de panneau"), content)
def test_export_assets_with_duplicate_content_types(self):
from django.urls import reverse
from django.contrib.contenttypes.models import ContentType
# Create a second duplicate ContentType with another app_label to simulate MultipleObjectsReturned
ContentType.objects.create(
app_label='other_app',
model='structuregeoasset'
)
self.client.force_login(self.user)
url = reverse('assets:export_assets')
ids_json = json.dumps([f"structuregeoasset:{self.asset.id}"])
# This should not raise MultipleObjectsReturned and succeed
response = self.client.post(url, {'ids': ids_json, 'format': 'csv'})
self.assertEqual(response.status_code, 200)
content = response.content.decode('utf-8')
self.assertIn("EXP01A-000001", content)
def test_export_signpanel_with_type(self):
from django.urls import reverse
from assets.models import SignPanel, SignPanelType, SignPole, SignStreet
thematic_sign = Thematic.objects.get_or_create(code="sign", defaults={"name_fr": "Signalisation"})[0]
UserThematics.objects.create(
user_config=self.user_config,
thematic=thematic_sign,
can_view_assets=True,
can_edit_assets=True,
)
street = SignStreet.objects.create(name_fr="Rue du Test", code="STR01")
pole = SignPole.objects.create(code="POL01", street=street)
panel_type = SignPanelType.objects.create(code="C15", name_fr="Accès interdit", name_nl="Verboden toegang")
panel = SignPanel.objects.create(
code="PAN01",
pole=pole,
street=street,
signpanel_type=panel_type,
status="active",
)
self.client.force_login(self.user)
url = reverse('assets:export_assets')
ids_json = json.dumps([f"signpanel:{panel.id}"])
response = self.client.post(url, {'ids': ids_json, 'format': 'csv'})
self.assertEqual(response.status_code, 200)
content = response.content.decode('utf-8')
from django.utils.translation import gettext as _
self.assertIn(_("Type de panneau"), content)
self.assertIn("C15 - Accès interdit", content)
self.assertIn("PAN01", content)
class UserAccessibleMunicipalitiesTest(TestCase):
def setUp(self):
from common.models import Municipality, UserConfig, UserContractAccess, Thematic, UserThematics
from contracts.models import Company, Contract
self.muni_a = Municipality.objects.create(name_fr="Commune A", name_nl="Gemeente A", fid="1001")
self.muni_b = Municipality.objects.create(name_fr="Commune B", name_nl="Gemeente B", fid="1002")
self.muni_c = Municipality.objects.create(name_fr="Commune C", name_nl="Gemeente C", fid="1003")
self.superuser = User.objects.create_superuser(username="admin_super", password="pass123")
self.user_intern = User.objects.create_user(username="user_intern", password="pass123")
UserConfig.objects.create(user=self.user_intern, is_intern=True, limit_assets_to_contracts=False)
self.user_ext = User.objects.create_user(username="user_ext", password="pass123")
self.ext_config = UserConfig.objects.create(user=self.user_ext, is_intern=False, limit_assets_to_contracts=True)
self.company = Company.objects.create(name="Test Company")
self.contract = Contract.objects.create(contract_number="CTR-001", company=self.company, start_date="2026-01-01", end_date="2026-12-31")
self.contract.municipalities.add(self.muni_a, self.muni_b)
UserContractAccess.objects.create(
user_config=self.ext_config,
contract=self.contract,
can_view_assets=True
)
thematic_roads, _ = Thematic.objects.get_or_create(code="roads", defaults={"name_fr": "Voiries"})
UserThematics.objects.create(user_config=self.ext_config, thematic=thematic_roads, can_view_assets=True)
def test_superuser_sees_all_municipalities(self):
from assets.permissions import get_user_accessible_municipalities
munis = list(get_user_accessible_municipalities(self.superuser))
self.assertEqual(len(munis), 3)
def test_intern_unrestricted_sees_all_municipalities(self):
from assets.permissions import get_user_accessible_municipalities
munis = list(get_user_accessible_municipalities(self.user_intern))
self.assertEqual(len(munis), 3)
def test_restricted_user_sees_only_contracted_municipalities(self):
from assets.permissions import get_user_accessible_municipalities
munis = list(get_user_accessible_municipalities(self.user_ext))
muni_ids = {m.id for m in munis}
self.assertIn(self.muni_a.id, muni_ids)
self.assertIn(self.muni_b.id, muni_ids)
self.assertNotIn(self.muni_c.id, muni_ids)
def test_restricted_user_without_contracts_sees_none(self):
from assets.permissions import get_user_accessible_municipalities
user_empty = User.objects.create_user(username="user_empty", password="pass123")
UserConfig.objects.create(user=user_empty, is_intern=False, limit_assets_to_contracts=True)
munis = list(get_user_accessible_municipalities(user_empty))
self.assertEqual(len(munis), 0)
def test_build_conditional_filter_config_restricted(self):
from assets.views.core import build_conditional_filter_config
thematic_roads = Thematic.objects.get(code="roads")
cfg = build_conditional_filter_config([thematic_roads], user=self.user_ext)
roads_filters = cfg.get('roads', [])
muni_filter = next((f for f in roads_filters if f['key'] == 'municipality'), None)
self.assertIsNotNone(muni_filter)
opt_values = {opt['value'] for opt in muni_filter['options']}
self.assertIn(str(self.muni_a.id), opt_values)
self.assertIn(str(self.muni_b.id), opt_values)
self.assertNotIn(str(self.muni_c.id), opt_values)
def test_roads_streets_geojson_restriction(self):
from assets.models.roads import RoadStreet
from django.urls import reverse
street_a = RoadStreet.objects.create(code="ST-A", name_fr="Rue A", municipality=self.muni_a)
street_c = RoadStreet.objects.create(code="ST-C", name_fr="Rue C", municipality=self.muni_c)
self.client.force_login(self.user_ext)
response = self.client.get(reverse('assets:roads_streets_geojson'))
self.assertEqual(response.status_code, 200)
data = response.json()
feature_ids = [f['id'] for f in data.get('features', [])]
self.assertIn(street_a.id, feature_ids)
self.assertNotIn(street_c.id, feature_ids)
def test_build_conditional_filter_config_single_municipality_hidden(self):
from assets.views.core import build_conditional_filter_config
# Remove muni_b from contract
self.contract.municipalities.remove(self.muni_b)
thematic_roads = Thematic.objects.get(code="roads")
cfg = build_conditional_filter_config([thematic_roads], user=self.user_ext)
roads_filters = cfg.get('roads', [])
muni_filter = next((f for f in roads_filters if f['key'] == 'municipality'), None)
# When only 1 municipality is accessible, the filter should NOT be shown
self.assertIsNone(muni_filter)