feat: add signpanel search support, symbology, and coordinate fallback in mobile asset views

This commit is contained in:
kdeterme 2026-09-13 15:01:23 +02:00
parent bd8897499b
commit 57976c71eb
6 changed files with 231 additions and 29 deletions

View file

@ -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 `<img src="${symbology.iconUrl}" alt="" class="${imgClass}"${customStyle}>`;
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 `<img src="${symbology.iconUrl}" alt="" class="${imgClass}"${customStyle}${onerrorAttr}><i class="bi ${fallbackClass}" style="display:none;${options.style || ''}"></i>`;
}
const iconClass = options.iconClass || symbology.iconClass || 'bi-pin-map';

View file

@ -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'),

View file

@ -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 @@
<span id="zoneIndicator" class="badge bg-info-subtle text-info-emphasis d-none" role="button" style="cursor: pointer;" title="{% translate 'Filtré selon la zone visible sur la carte. Cliquez pour élargir à toute la commune.' %}">
<i class="bi bi-crop me-1"></i>{% translate "Zone visible" %} <i class="bi bi-x ms-1"></i>
</span>
<span id="locationIndicator" class="text-primary d-none ms-1">
<i class="bi bi-geo-alt-fill"></i> {% translate "Trié par distance" %}
</span>
</div>
<div id="loadingSpinner" class="spinner-border spinner-border-sm text-primary d-none" role="status">
<span class="visually-hidden">{% translate "Chargement..." %}</span>
@ -629,7 +644,7 @@
<div class="fw-semibold text-dark mb-1" id="sheetName">-</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span id="sheetStatusBadge" class="badge-status badge-status-active">Actif</span>
<span id="sheetElementType" class="badge bg-light text-secondary border">Élément</span>
<span id="sheetElementType" class="badge-element-type">Élément</span>
<span id="sheetDistanceBadge" class="badge bg-primary-subtle text-primary d-none">
<i class="bi bi-geo-alt me-1"></i><span id="sheetDistanceText"></span>
</span>
@ -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)
: `<i class="bi ${symbology.iconClass || 'bi-pin-map'}"></i>`;
const hasCustomName = asset.name && asset.name.trim() && asset.name.toLowerCase() !== (asset.element_type || '').toLowerCase();
const typeBadgeHtml = asset.element_type
? `<span class="badge-element-type text-nowrap flex-shrink-0">${escapeHtml(asset.element_type)}</span>`
: '';
const nameHtml = hasCustomName
? `<span class="text-truncate flex-grow-1">${escapeHtml(asset.name)}</span>`
: (!asset.element_type && asset.thematic_name ? `<span class="text-truncate flex-grow-1">${escapeHtml(asset.thematic_name)}</span>` : '');
card.innerHTML = `
<div class="asset-icon-circle" style="background-color: ${thematicColor}15; color: ${thematicColor};">
${iconHtml}
@ -1249,9 +1271,12 @@
<div class="flex-grow-1 text-truncate">
<div class="d-flex align-items-center gap-1.5 flex-wrap">
<span class="fw-bold text-dark text-truncate">${escapeHtml(asset.code)}</span>
<span class="${statusClass}">${escapeHtml(asset.status_display)}</span>
<span class="${statusClass}">${escapeHtml(asset.status_display || asset.status || 'Actif')}</span>
</div>
<div class="small text-secondary d-flex align-items-center gap-1.5 mt-0.5 overflow-hidden">
${typeBadgeHtml}
${nameHtml}
</div>
<div class="small text-secondary text-truncate">${escapeHtml(asset.name || asset.element_type || asset.thematic_name)}</div>
</div>
${distanceBadge}
<i class="bi bi-chevron-right text-muted"></i>
@ -1399,6 +1424,14 @@
? `<span class="badge bg-primary-subtle text-primary ms-auto me-1 text-nowrap"><i class="bi bi-geo-alt me-1"></i>${formatDistance(asset.distance_m)}</span>`
: '';
const hasCustomName = asset.name && asset.name.trim() && asset.name.toLowerCase() !== (asset.element_type || '').toLowerCase();
const typeBadgeHtml = asset.element_type
? `<span class="badge-element-type text-nowrap flex-shrink-0">${escapeHtml(asset.element_type)}</span>`
: '';
const nameHtml = hasCustomName
? `<span class="text-truncate flex-grow-1">${escapeHtml(asset.name)}</span>`
: (!asset.element_type && asset.thematic_name ? `<span class="text-truncate flex-grow-1">${escapeHtml(asset.thematic_name)}</span>` : '');
const card = document.createElement('div');
card.className = 'multi-asset-card';
card.innerHTML = `
@ -1410,7 +1443,10 @@
<span class="fw-bold text-dark text-truncate">${escapeHtml(asset.code)}</span>
<span class="${statusClass}">${escapeHtml(asset.status_display || asset.status || 'Actif')}</span>
</div>
<div class="small text-secondary text-truncate">${escapeHtml(asset.name || asset.element_type || asset.thematic_name || '')}</div>
<div class="small text-secondary d-flex align-items-center gap-1.5 mt-0.5 overflow-hidden">
${typeBadgeHtml}
${nameHtml}
</div>
</div>
${distanceBadge}
<i class="bi bi-chevron-right text-muted flex-shrink-0"></i>
@ -1511,8 +1547,12 @@
: `<i class="bi ${symbology.iconClass || 'bi-pin-map'}" id="sheetIcon"></i>`;
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) {

View file

@ -142,7 +142,7 @@
<div class="icon-wrapper">
<i class="bi bi-pin-map"></i>
</div>
<h2 class="fw-semibold">{% translate "Patrimoine & Assets" %}</h2>
<h2 class="fw-semibold">{% translate "Assets" %}</h2>
<p>{% translate "Consulter et localiser les assets sur le terrain" %}</p>
</a>
</div>

View file

@ -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 &amp; Assets" in content or
"Assets &amp; 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)

View file

@ -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,