4793 lines
200 KiB
Python
4793 lines
200 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: Pedestrian phase aF 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: Pedestrian phase aF
|
|
ws.cell(row=8, column=1, value="com")
|
|
ws.cell(row=8, column=2, value="1")
|
|
ws.cell(row=8, column=3, value="aF")
|
|
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_aF")
|
|
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_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)
|
|
|
|
|
|
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)
|