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,