diff --git a/loko/assets/static/assets/asset_symbology.js b/loko/assets/static/assets/asset_symbology.js
index b967289..b82e974 100644
--- a/loko/assets/static/assets/asset_symbology.js
+++ b/loko/assets/static/assets/asset_symbology.js
@@ -513,17 +513,34 @@
return Object.assign({}, PARKING_SPOT_OVERRIDES[spotType]);
}
- // 2. Specific asset_type override
+ // 2. Road sign panel specific override (dynamic road sign face SVG/PNG)
+ if (assetType === 'signpanel' || (thematic === 'sign' && (item.panel_type || item.svg_url || item.icon_url))) {
+ const panelCode = (item.panel_type || '').trim().toUpperCase();
+ const iconUrl = item.icon_url || item.svg_url || (panelCode ? `/static/assets/road_signs/2025/${panelCode}.svg` : null);
+ const fallbackUrl = panelCode ? `/static/assets/road_signs/2025/${panelCode}.png` : null;
+ return {
+ thematic: 'sign',
+ color: '#0022ff',
+ iconUrl: iconUrl,
+ fallbackUrl: fallbackUrl,
+ iconClass: 'bi-sign-stop-fill',
+ glyphCodepoint: 'f82b',
+ mapImage: panelCode ? `road-sign-${panelCode.toLowerCase()}` : 'icon-sign',
+ iconSize: 40,
+ };
+ }
+
+ // 3. Specific asset_type override
if (assetType && ASSET_TYPE_OVERRIDES[assetType]) {
return Object.assign({}, ASSET_TYPE_OVERRIDES[assetType]);
}
- // 3. Fallback to thematic configuration
+ // 4. Fallback to thematic configuration
if (thematic && THEMATIC_CONFIG[thematic]) {
return Object.assign({}, THEMATIC_CONFIG[thematic]);
}
- // 4. Ultimate fallback
+ // 5. Ultimate fallback
return {
color: THEMATIC_COLORS[thematic] || '#2f3e8b',
iconUrl: null,
@@ -555,7 +572,10 @@
if (symbology.iconUrl) {
const imgClass = options.imgClass || 'asset-symbology-icon-img';
- return `
`;
+ const fallbackUrl = symbology.fallbackUrl || (symbology.iconUrl.includes('.svg') ? symbology.iconUrl.replace(/\.svg(\?.*)?$/, '.png$1') : '');
+ const fallbackClass = options.iconClass || symbology.iconClass || 'bi-sign-stop-fill';
+ const onerrorAttr = ` onerror="this.onerror=null; ${fallbackUrl ? `if(this.src && this.src.indexOf('.svg') !== -1){ this.src='${fallbackUrl}'; } else {` : ''} this.style.display='none'; if(this.nextElementSibling){ this.nextElementSibling.style.display=''; } ${fallbackUrl ? '}' : ''}"`;
+ return `
`;
}
const iconClass = options.iconClass || symbology.iconClass || 'bi-pin-map';
diff --git a/loko/assets/views/core.py b/loko/assets/views/core.py
index 6e3fd9c..5aed2fc 100644
--- a/loko/assets/views/core.py
+++ b/loko/assets/views/core.py
@@ -420,6 +420,7 @@ ELEMENT_TYPE_LABELS = {
'street': _('Rue / Voie'),
'roadway': _('Chaussée'),
'sidewalk': _('Trottoir'),
+ 'roadsidewalk': _('Trottoir'),
'roadfurniture': _('Mobilier voirie'),
'roaddrain': _('Drain'),
'roadconcreteblockset': _('Blocs béton'),
@@ -431,8 +432,10 @@ ELEMENT_TYPE_LABELS = {
'signpole': _('Poteau signalisation'),
'signpanel': _('Panneau'),
'signparkingspot': _('Place de parking'),
+ 'parkingspot': _('Place de parking'),
# --- Tricolores ---
'trafficlightpole': _('Poteau tricolore'),
+ 'trafficlightradar': _('Radar TL'),
'trafficlightcable': _('Câble TL'),
'trafficlightlantern': _('Lanterne TL'),
'trafficlightcontroller': _('Contrôleur TL'),
diff --git a/loko/mobile/templates/mobile/assets/assets_index_mobile.html b/loko/mobile/templates/mobile/assets/assets_index_mobile.html
index a3d1d5f..2ed375e 100644
--- a/loko/mobile/templates/mobile/assets/assets_index_mobile.html
+++ b/loko/mobile/templates/mobile/assets/assets_index_mobile.html
@@ -3,7 +3,7 @@
{% load i18n %}
{% block title %}
- {% translate "Patrimoine & Assets" %}
+ {% translate "Assets" %}
{% endblock title %}
{% block extra_head %}
@@ -266,8 +266,8 @@
}
.asset-map-marker img,
.asset-symbology-icon-img {
- width: 20px;
- height: 20px;
+ width: 22px;
+ height: 22px;
object-fit: contain;
display: block;
pointer-events: none;
@@ -413,11 +413,15 @@
}
.asset-icon-circle img,
#sheetIconCircle img {
- width: 24px;
- height: 24px;
+ width: 26px;
+ height: 26px;
object-fit: contain;
display: block;
}
+ .multi-asset-card .asset-icon-circle img {
+ width: 22px;
+ height: 22px;
+ }
/* Badges de statuts */
.badge-status {
@@ -432,6 +436,20 @@
.badge-status-planned { background-color: #e0e7ff; color: #3730a3; }
.badge-status-removed { background-color: #f1f5f9; color: #475569; }
+ /* Badges de types d'asset */
+ .badge-element-type {
+ font-size: 0.70rem;
+ font-weight: 500;
+ line-height: 1.25;
+ padding: 0.15rem 0.45rem;
+ border-radius: 0.35rem;
+ background-color: #eff6ff;
+ color: #1e40af;
+ border: 1px solid #bfdbfe;
+ display: inline-block;
+ vertical-align: middle;
+ }
+
.spin-once {
animation: spinOnce 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
@@ -526,9 +544,6 @@
{% translate "Zone visible" %}
-
- {% translate "Trié par distance" %}
-
{% translate "Chargement..." %}
@@ -629,7 +644,7 @@
-
Actif
-
Élément
+
Élément
@@ -757,7 +772,6 @@
const zoneIndicator = document.getElementById('zoneIndicator');
const resultsCountEl = document.getElementById('resultsCount');
const loadingSpinner = document.getElementById('loadingSpinner');
- const locationIndicator = document.getElementById('locationIndicator');
const btnGpsLocate = document.getElementById('btnGpsLocate');
const btnResetFilters = document.getElementById('btnResetFilters');
@@ -1242,6 +1256,14 @@
? AssetSymbology.renderIconHtml(symbology)
: `
`;
+ const hasCustomName = asset.name && asset.name.trim() && asset.name.toLowerCase() !== (asset.element_type || '').toLowerCase();
+ const typeBadgeHtml = asset.element_type
+ ? `
${escapeHtml(asset.element_type)}`
+ : '';
+ const nameHtml = hasCustomName
+ ? `
${escapeHtml(asset.name)}`
+ : (!asset.element_type && asset.thematic_name ? `
${escapeHtml(asset.thematic_name)}` : '');
+
card.innerHTML = `
${iconHtml}
@@ -1249,9 +1271,12 @@
${escapeHtml(asset.code)}
- ${escapeHtml(asset.status_display)}
+ ${escapeHtml(asset.status_display || asset.status || 'Actif')}
+
+
+ ${typeBadgeHtml}
+ ${nameHtml}
-
${escapeHtml(asset.name || asset.element_type || asset.thematic_name)}
${distanceBadge}
@@ -1399,6 +1424,14 @@
? `
${formatDistance(asset.distance_m)}`
: '';
+ const hasCustomName = asset.name && asset.name.trim() && asset.name.toLowerCase() !== (asset.element_type || '').toLowerCase();
+ const typeBadgeHtml = asset.element_type
+ ? `
${escapeHtml(asset.element_type)}`
+ : '';
+ const nameHtml = hasCustomName
+ ? `
${escapeHtml(asset.name)}`
+ : (!asset.element_type && asset.thematic_name ? `
${escapeHtml(asset.thematic_name)}` : '');
+
const card = document.createElement('div');
card.className = 'multi-asset-card';
card.innerHTML = `
@@ -1410,7 +1443,10 @@
${escapeHtml(asset.code)}
${escapeHtml(asset.status_display || asset.status || 'Actif')}
-
${escapeHtml(asset.name || asset.element_type || asset.thematic_name || '')}
+
+ ${typeBadgeHtml}
+ ${nameHtml}
+
${distanceBadge}
@@ -1511,8 +1547,12 @@
: `
`;
sheetStatusBadge.className = `badge-status badge-status-${asset.status || 'active'}`;
- sheetStatusBadge.textContent = asset.status_display || asset.status || 'Actif';
- sheetElementType.textContent = asset.element_type || '';
+ if (asset.element_type) {
+ sheetElementType.textContent = asset.element_type;
+ sheetElementType.classList.remove('d-none');
+ } else {
+ sheetElementType.classList.add('d-none');
+ }
if (asset.distance_m !== null && asset.distance_m !== undefined) {
sheetDistanceBadge.classList.remove('d-none');
@@ -1604,7 +1644,6 @@
lat: pos.coords.latitude,
lon: pos.coords.longitude,
};
- locationIndicator.classList.remove('d-none');
// Marqueur sur la carte
if (map) {
diff --git a/loko/mobile/templates/mobile/mobile_index.html b/loko/mobile/templates/mobile/mobile_index.html
index acd8787..60b181e 100644
--- a/loko/mobile/templates/mobile/mobile_index.html
+++ b/loko/mobile/templates/mobile/mobile_index.html
@@ -142,7 +142,7 @@
-
{% translate "Patrimoine & Assets" %}
+
{% translate "Assets" %}
{% translate "Consulter et localiser les assets sur le terrain" %}
diff --git a/loko/mobile/tests_asset_mobile.py b/loko/mobile/tests_asset_mobile.py
index db54bf8..1945312 100644
--- a/loko/mobile/tests_asset_mobile.py
+++ b/loko/mobile/tests_asset_mobile.py
@@ -33,6 +33,14 @@ class MobileAssetDetailTests(TestCase):
"icon": "bi-p-square"
}
)
+ self.sign_thematic, _ = Thematic.objects.get_or_create(
+ code="sign",
+ defaults={
+ "name_fr": "Signalisation",
+ "name_nl": "Signalisatie",
+ "icon": "bi-sign-stop"
+ }
+ )
# Create Roles
self.admin_role, _ = Role.objects.get_or_create(name="admin")
@@ -237,7 +245,7 @@ class MobileAssetDetailTests(TestCase):
self.assertEqual(self.tree.tag_number, "TAG-99999")
def test_mobile_index_shows_assets_card_when_authorized(self):
- """User with asset view rights sees 'Patrimoine & Assets' card on mobile home."""
+ """User with asset view rights sees 'Assets' card on mobile home."""
user = User.objects.create_user(username="asset_viewer", password="password")
user_config = UserConfig.objects.create(user=user, is_intern=True)
user_config.roles.add(self.viewer_role)
@@ -253,15 +261,10 @@ class MobileAssetDetailTests(TestCase):
self.assertTrue(response.context["can_view_assets"])
self.assertContains(response, reverse("mobile:mobile_assets_index"))
content = response.content.decode()
- self.assertTrue(
- "Patrimoine & Assets" in content or
- "Assets & Heritage" in content or
- "Patrimoine & Assets" in content or
- "Assets & Heritage" in content
- )
+ self.assertTrue("Assets" in content)
def test_mobile_index_hides_assets_card_when_unauthorized(self):
- """User without asset view rights does NOT see 'Patrimoine & Assets' card on mobile home."""
+ """User without asset view rights does NOT see 'Assets' card on mobile home."""
user = User.objects.create_user(username="no_asset_user", password="password")
user_config = UserConfig.objects.create(user=user, is_intern=True)
user_config.roles.add(self.viewer_role)
@@ -474,6 +477,9 @@ class MobileAssetDetailTests(TestCase):
self.assertEqual(feature["properties"]["geom_type"], "MultiPolygon")
self.assertEqual(feature["properties"]["bbox"], result_item["bbox"])
self.assertEqual(feature["id"], f"naturegreensurface_{self.green_surface.id}")
+ self.assertIn("element_type", result_item)
+ self.assertIn("element_type", feature["properties"])
+ self.assertTrue(len(result_item["element_type"]) > 0)
# 2. Test parking search with parking spot (Polygon)
response = self.client.get(search_url, {"thematic": "parking", "q": "PK-SPOT-001"})
@@ -485,8 +491,81 @@ class MobileAssetDetailTests(TestCase):
self.assertEqual(spot_res["geom_type"], "Polygon")
self.assertIsNotNone(spot_res["bbox"])
self.assertEqual(len(spot_res["bbox"]), 4)
+ self.assertIn("element_type", spot_res)
+ self.assertTrue(len(spot_res["element_type"]) > 0)
spot_feat = data["geojson"]["features"][0]
self.assertEqual(spot_feat["geometry"]["type"], "Polygon")
self.assertEqual(spot_feat["properties"]["geom_type"], "Polygon")
+ self.assertIn("element_type", spot_feat["properties"])
+
+ def test_search_signpanel_symbology_and_coordinates(self):
+ """SignPanel returns panel_type, svg_url, icon_url and inherits pole coordinates."""
+ user = User.objects.create_user(username="sign_search_user", password="password")
+ user_config = UserConfig.objects.create(user=user, is_intern=True)
+ user_config.roles.add(self.admin_role)
+ UserThematics.objects.create(
+ user_config=user_config,
+ thematic=self.sign_thematic,
+ can_view_assets=True
+ )
+
+ from assets.models import SignPanel, SignPole, SignStreet, SignPanelType
+ from common.models import Municipality
+ mun, _ = Municipality.objects.get_or_create(name_fr="Bruxelles", defaults={"name_nl": "Brussel", "niscode": "21004"})
+ street = SignStreet.objects.create(name_fr="Avenue Louise", municipality=mun)
+ pole = SignPole.objects.create(
+ code="POT-001",
+ street=street,
+ lat=50.8466,
+ lon=4.3528,
+ status="active",
+ )
+ panel_type, _ = SignPanelType.objects.get_or_create(
+ code="B1",
+ defaults={
+ "name_fr": "Cédez le passage",
+ "name_nl": "Voorrang verlenen",
+ }
+ )
+ panel = SignPanel.objects.create(
+ code="PAN-B1-001",
+ street=street,
+ pole=pole,
+ signpanel_type=panel_type,
+ status="active",
+ )
+
+ self.client.login(username="sign_search_user", password="password")
+ search_url = reverse("mobile:mobile_assets_search")
+
+ # 1. Search without q filter
+ response = self.client.get(search_url, {"thematic": "sign"})
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertTrue(data["count"] >= 1)
+
+ panel_item = next(item for item in data["results"] if item["id"] == panel.id and item["model_key"] == "signpanel")
+ self.assertEqual(panel_item["panel_type"], "B1")
+ self.assertEqual(panel_item["svg_url"], "/static/assets/road_signs/2025/B1.svg")
+ self.assertEqual(panel_item["icon_url"], "/static/assets/road_signs/2025/B1.svg")
+ self.assertAlmostEqual(panel_item["lat"], 50.8466, places=4)
+ self.assertAlmostEqual(panel_item["lon"], 4.3528, places=4)
+ self.assertIn("B1", panel_item["name"])
+ self.assertIn("Cédez le passage", panel_item["name"])
+
+ feature = next(f for f in data["geojson"]["features"] if f["id"] == f"signpanel_{panel.id}")
+ self.assertEqual(feature["properties"]["panel_type"], "B1")
+ self.assertEqual(feature["properties"]["svg_url"], "/static/assets/road_signs/2025/B1.svg")
+ self.assertEqual(feature["properties"]["icon_url"], "/static/assets/road_signs/2025/B1.svg")
+ self.assertAlmostEqual(feature["geometry"]["coordinates"][1], 50.8466, places=4)
+ self.assertAlmostEqual(feature["geometry"]["coordinates"][0], 4.3528, places=4)
+
+ # 2. Search by panel type name
+ response = self.client.get(search_url, {"thematic": "sign", "q": "Cédez"})
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["count"], 1)
+ self.assertEqual(data["results"][0]["id"], panel.id)
+
diff --git a/loko/mobile/views.py b/loko/mobile/views.py
index 3d1e25a..6631ef1 100644
--- a/loko/mobile/views.py
+++ b/loko/mobile/views.py
@@ -1,3 +1,4 @@
+import re
from functools import wraps
from django.shortcuts import render, redirect
from django.contrib import messages
@@ -1387,6 +1388,10 @@ def mobile_assets_search(request):
search_q |= Q(name_fr__icontains=q)
if hasattr(model_class, 'name_nl'):
search_q |= Q(name_nl__icontains=q)
+ if model_key == 'signpanel' and hasattr(model_class, 'signpanel_type'):
+ search_q |= Q(signpanel_type__code__icontains=q)
+ search_q |= Q(signpanel_type__name_fr__icontains=q)
+ search_q |= Q(signpanel_type__name_nl__icontains=q)
qs = qs.filter(search_q)
# Nombre total d'assets correspondants (pour savoir si limite atteinte)
@@ -1415,6 +1420,10 @@ def mobile_assets_search(request):
features = []
for model_key, model_class, qs in model_querysets:
+ if model_key == 'signpanel' and hasattr(model_class, 'signpanel_type'):
+ qs = qs.select_related('signpanel_type', 'model', 'pole')
+ elif model_key == 'trafficlightlantern' and hasattr(model_class, 'model'):
+ qs = qs.select_related('model', 'pole')
items = list(qs)
for item in items:
lat = None
@@ -1476,6 +1485,30 @@ def mobile_assets_search(request):
except (ValueError, TypeError):
pass
+ if (lon is None or lat is None):
+ for support_attr in ('pole', 'trafficlight_pole', 'publiclighting_pole'):
+ if hasattr(item, support_attr):
+ support = getattr(item, support_attr, None)
+ if support:
+ if hasattr(support, 'lat') and support.lat is not None and hasattr(support, 'lon') and support.lon is not None:
+ try:
+ lat = float(str(support.lat).replace(',', '.'))
+ lon = float(str(support.lon).replace(',', '.'))
+ break
+ except (ValueError, TypeError):
+ pass
+ if hasattr(support, 'geom') and support.geom:
+ try:
+ s_geom = support.geom.clone()
+ if s_geom.srid != 4326:
+ s_geom.transform(4326)
+ c = s_geom.centroid
+ lon = float(c.x)
+ lat = float(c.y)
+ break
+ except Exception:
+ pass
+
# 4. Fallback si geometry est absent mais qu'on a un point lon/lat
if not geometry and lon is not None and lat is not None:
geometry = {
@@ -1507,9 +1540,31 @@ def mobile_assets_search(request):
(getattr(item.model, f'name_{lang}', None) or getattr(item.model, 'name_fr', None) if getattr(item, 'model', None) else '') or
''
)
+
+ signpanel_type = getattr(item, 'signpanel_type', None) if model_key == 'signpanel' else None
+ panel_type_code = getattr(signpanel_type, 'code', None) if signpanel_type else None
+ if panel_type_code:
+ panel_type_code = str(panel_type_code).strip()
+ elif model_key == 'signpanel':
+ raw_code = getattr(item, 'code', '') or ''
+ if re.match(r'^[A-Za-z]{1,3}\d{1,3}[A-Za-z]?$', raw_code.strip()):
+ panel_type_code = raw_code.strip()
+
+ svg_url = f"/static/assets/road_signs/2025/{panel_type_code.upper()}.svg" if panel_type_code else None
+
+ if model_key == 'signpanel':
+ if signpanel_type:
+ sign_name = getattr(signpanel_type, f'name_{lang}', None) or getattr(signpanel_type, 'name_fr', None) or ''
+ if not item_name:
+ item_name = f"{panel_type_code} - {sign_name}" if panel_type_code and sign_name else (sign_name or panel_type_code or '')
+ elif not item_name and panel_type_code:
+ item_name = panel_type_code
+
status_val = getattr(item, 'status', 'active')
status_display = item.get_status_display() if hasattr(item, 'get_status_display') else status_val
element_type = get_element_type_label(model_key)
+ if model_key == 'parkingspot' and hasattr(item, 'get_spot_type_display') and item.spot_type:
+ element_type = str(item.get_spot_type_display())
detail_url = reverse('mobile:mobile_asset_detail_direct', kwargs={'asset_model': model_key, 'asset_id': item.id})
spot_type = getattr(item, 'spot_type', None)
@@ -1527,6 +1582,9 @@ def mobile_assets_search(request):
'thematic_icon': thematic_icon,
'model_key': model_key,
'asset_type': model_key,
+ 'panel_type': panel_type_code,
+ 'svg_url': svg_url,
+ 'icon_url': svg_url,
'spot_type': spot_type,
'element_type': element_type,
'geom_type': geom_type,
@@ -1558,6 +1616,9 @@ def mobile_assets_search(request):
'thematic_icon': thematic_icon,
'model_key': model_key,
'asset_type': model_key,
+ 'panel_type': panel_type_code,
+ 'svg_url': svg_url,
+ 'icon_url': svg_url,
'spot_type': spot_type,
'element_type': element_type,
'geom_type': geom_type,