- {% if document.thumbnail %}
-

- {% else %}
-

+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ section.code }} — {{ section.get_name }}
+
+
+
+ {% if section.municipality %}
+ {{ section.municipality }}
+ •
+ {% endif %}
+
+ {% translate "Zone:" %}
+ {% if section.zone %}
+ Zone {{ section.zone }}
+ {% else %}
+ {% translate "Non définie" %}
+ {% endif %}
+
+ •
+
+ {% translate "Autoroute:" %}
+ {% if section.is_highway %}
+ {% translate "Oui" %}
+ {% else %}
+ {% translate "Non" %}
+ {% endif %}
+
+ {% if section.category %}
+ •
+ {{ section.category.get_name }}
{% endif %}
-
+
+
+
+ {% if section.parent_location or section.child_locations.exists %}
+
+ {% if section.parent_location %}
+
+ {% endif %}
+ {% if section.child_locations.exists %}
+
+
{% translate "Sous-sections :" %}
+ {% for child in section.child_locations.all %}
+
+ {{ child.code }}
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+
+ {% translate "Total équipements" %}
+ {{ total_assets_count }}
+
+
+ {% translate "Interventions" %}
+ {{ interventions|length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% translate "Nombre d'arbres:" %}
+
{{ trees_count }}
+
+ {{ trees_active_count }} {% translate "actifs" %}
+ {% if trees_remarkable_count %}
+ • {{ trees_remarkable_count }} {% translate "remarquables" %}
+ {% endif %}
+
- {% endfor %}
-
-
-
+
+
+
+
-
-
-{% endblock content%}
-
+{% endblock content %}
{% block script %}
- {{ section.lon|json_script:"lon_value" }}
- {{ section.lat|json_script:"lat_value" }}
- {{ section.geojson|json_script:"geojson_value" }}
+ {{ feature_lon|json_script:"lon_value" }}
+ {{ feature_lat|json_script:"lat_value" }}
+ {{ section_geojson|json_script:"section_geojson_value" }}
+ {{ assets_geojson|json_script:"assets_geojson_value" }}
-
+
+
{% endblock script %}
diff --git a/loko/assets/tests.py b/loko/assets/tests.py
index b867ad5..fb11182 100644
--- a/loko/assets/tests.py
+++ b/loko/assets/tests.py
@@ -5440,3 +5440,156 @@ class UserAccessibleMunicipalitiesTest(TestCase):
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)
+
+
+class NatureLocationDetailViewTest(TestCase):
+ def setUp(self):
+ from assets.models import NatureGreenSurface, NatureTree, NatureShrub, NatureBench, NatureAssetModel
+ self.thematic = Thematic.objects.filter(code="nature").first()
+ if not self.thematic:
+ self.thematic = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur")
+
+ self.user = User.objects.create_user(username="nature_detail_user", password="password123")
+ self.config = UserConfig.objects.create(user=self.user)
+ UserThematics.objects.create(
+ user_config=self.config,
+ thematic=self.thematic,
+ can_view_assets=True
+ )
+
+ from django.contrib.gis.geos import Polygon, MultiPolygon
+ poly = Polygon(((648000, 670000), (648100, 670000), (648100, 670100), (648000, 670100), (648000, 670000)), srid=3812)
+ multipoly = MultiPolygon(poly, srid=3812)
+ geom_4326 = multipoly.clone()
+ geom_4326.transform(4326)
+
+ self.location = NatureLocation.objects.create(
+ code="FOR_N241_007",
+ name_fr="Avenue Wielemans Ceuppens",
+ name_nl="Wielemans Ceuppenslaan",
+ geom=multipoly,
+ geojson=geom_4326.json,
+ lon=4.33,
+ lat=50.82,
+ zone=2,
+ is_highway=False
+ )
+
+ # Asset models
+ self.model_arbore = NatureAssetModel.objects.create(
+ code="MOD_ARBORE",
+ name_fr="Massif arboré",
+ name_nl="Boomrijk massief",
+ color="#2e7d32"
+ )
+ self.model_pre = NatureAssetModel.objects.create(
+ code="MOD_FLEURI",
+ name_fr="Pré fleuri",
+ name_nl="Bloemenweide",
+ color="#ab47bc"
+ )
+ self.model_tree = NatureAssetModel.objects.create(
+ code="TREE_QUERCUS",
+ name_fr="Chêne pédonculé",
+ scientific_name="Quercus robur",
+ color="#2e7d32"
+ )
+
+ # Assets
+ self.gs1 = NatureGreenSurface.objects.create(
+ code="GS-001",
+ location=self.location,
+ model=self.model_arbore,
+ surface_area=250.0,
+ status="active"
+ )
+ self.gs2 = NatureGreenSurface.objects.create(
+ code="GS-002",
+ location=self.location,
+ model=self.model_pre,
+ surface_area=120.5,
+ status="active"
+ )
+ self.tree1 = NatureTree.objects.create(
+ code="TR-001",
+ location=self.location,
+ model=self.model_tree,
+ height=12.0,
+ circumference=85.0,
+ vitality="strong",
+ development_stage="mature",
+ is_remarkable=True,
+ status="active",
+ lon=4.3301,
+ lat=50.8201
+ )
+ self.shrub = NatureShrub.objects.create(
+ code="SHR-001",
+ location=self.location,
+ height=1.5,
+ trimmed=True,
+ status="active"
+ )
+ self.bench = NatureBench.objects.create(
+ code="BNC-001",
+ location=self.location,
+ material="Bois",
+ status="active"
+ )
+
+ def test_nature_locations_detail_view(self):
+ self.client.force_login(self.user)
+ url = reverse('assets:nature_locations_detail', kwargs={'location_id': self.location.id})
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "FOR_N241_007")
+ self.assertContains(response, "Avenue Wielemans Ceuppens")
+ self.assertContains(response, "Zone 2")
+
+ # KPI counts and areas
+ self.assertEqual(response.context['trees_count'], 1)
+ self.assertEqual(response.context['trees_remarkable_count'], 1)
+ self.assertEqual(response.context['green_surfaces_count'], 2)
+ self.assertAlmostEqual(response.context['total_green_surface'], 370.5, places=1)
+ self.assertAlmostEqual(response.context['surface_massif_arbore'], 250.0, places=1)
+ self.assertAlmostEqual(response.context['surface_pre_fleuri'], 120.5, places=1)
+ self.assertEqual(response.context['shrubs_count'], 1)
+ self.assertEqual(response.context['benches_count'], 1)
+ self.assertEqual(response.context['total_assets_count'], 5)
+
+ # Asset codes present in HTML
+ self.assertContains(response, "GS-001")
+ self.assertContains(response, "GS-002")
+ self.assertContains(response, "TR-001")
+ self.assertContains(response, "SHR-001")
+ self.assertContains(response, "BNC-001")
+ self.assertContains(response, "Quercus robur")
+ self.assertContains(response, "nature_location_detail.js")
+
+ def test_nature_locations_detail_view_empty(self):
+ empty_loc = NatureLocation.objects.create(
+ code="LOC_EMPTY_01",
+ name_fr="Rue Vide",
+ name_nl="Lege Straat",
+ zone=None,
+ is_highway=False
+ )
+ self.client.force_login(self.user)
+ url = reverse('assets:nature_locations_detail', kwargs={'location_id': empty_loc.id})
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "LOC_EMPTY_01")
+ self.assertContains(response, "Rue Vide")
+ self.assertEqual(response.context['trees_count'], 0)
+ self.assertEqual(response.context['green_surfaces_count'], 0)
+ self.assertEqual(response.context['total_green_surface'], 0.0)
+ self.assertEqual(response.context['surface_massif_arbore'], 0.0)
+ self.assertEqual(response.context['surface_pre_fleuri'], 0.0)
+ self.assertEqual(response.context['total_assets_count'], 0)
+ self.assertEqual(len(response.context['interventions']), 0)
+ self.assertEqual(len(response.context['documents']), 0)
+ self.assertContains(response, "Aucune surface végétalisée enregistrée")
+ self.assertContains(response, "Aucun arbre enregistré")
+
diff --git a/loko/assets/views/nature.py b/loko/assets/views/nature.py
index 731ee8c..e483ba6 100644
--- a/loko/assets/views/nature.py
+++ b/loko/assets/views/nature.py
@@ -5,7 +5,7 @@ from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
from django.contrib.gis.db.models.functions import Distance
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import get_language, gettext as _
-from django.db.models import F, Func, Q, Case, When, CharField, IntegerField, Value
+from django.db.models import F, Func, Q, Case, When, CharField, IntegerField, Value, Sum, Count
from django.db.models import Max
from django.db.models.functions import Lower
from django.views.decorators.http import require_GET
@@ -23,7 +23,7 @@ from common.models import UserThematics
from assets.models import NatureLocation, NatureGreenSurface, NatureAssetModel, NatureTree, NatureShrub, NatureBench, AssetCategory, NatureRWIZ, NatureRWIAsset
from assets.models import GreenSurfacesComment, GreenSurfacesCommentPhoto
-from interventions.models import InterventionLocation
+from interventions.models import Intervention, InterventionLocation, InterventionAsset
from interventions.views.drafts import InterventionDraftManager
@check_thematic_access('nature')
@@ -118,11 +118,304 @@ def nature_locations_detail(request, location_id):
section = get_object_or_404(NatureLocation, id=location_id)
content_type = ContentType.objects.get_for_model(section)
- interventions = [obj.intervention for obj in InterventionLocation.objects.filter(content_type=content_type, object_id=section.id).select_related('intervention').order_by('-intervention__creation_time')]
+ # 1. Direct assets linked to this location (evaluated once)
+ trees = list(section.nature_trees.select_related('model', 'category').all())
+ green_surfaces = list(section.nature_green_surfaces.select_related('model', 'category').all())
+ shrubs = list(section.nature_shrubs.select_related('model', 'category').all())
+ benches = list(section.nature_benches.select_related('model', 'category').all())
+
+ # 2. Key figures & metrics
+ trees_count = len(trees)
+ trees_active_count = sum(1 for t in trees if getattr(t, 'status', None) == 'active')
+ trees_remarkable_count = sum(1 for t in trees if getattr(t, 'is_remarkable', False))
+
+ green_surfaces_count = len(green_surfaces)
+ total_green_surface = sum((gs.surface_area or 0.0) for gs in green_surfaces)
+
+ def is_massif_arbore(gs):
+ if not getattr(gs, 'model', None):
+ return False
+ n_fr = (gs.model.name_fr or '').lower()
+ n_nl = (gs.model.name_nl or '').lower()
+ code = (gs.model.code or '').upper()
+ return (
+ 'arboré' in n_fr or 'arbore' in n_fr or
+ 'boom' in n_nl or 'hout' in n_nl or
+ 'ARBORE' in code
+ )
+
+ def is_pre_fleuri(gs):
+ if not getattr(gs, 'model', None):
+ return False
+ n_fr = (gs.model.name_fr or '').lower()
+ n_nl = (gs.model.name_nl or '').lower()
+ code = (gs.model.code or '').upper()
+ return (
+ 'fleuri' in n_fr or 'fleur' in n_fr or
+ 'bloem' in n_nl or
+ 'FLEURI' in code
+ )
+
+ surface_massif_arbore = sum((gs.surface_area or 0.0) for gs in green_surfaces if is_massif_arbore(gs))
+ massif_arbore_count = sum(1 for gs in green_surfaces if is_massif_arbore(gs))
+
+ surface_pre_fleuri = sum((gs.surface_area or 0.0) for gs in green_surfaces if is_pre_fleuri(gs))
+ pre_fleuri_count = sum(1 for gs in green_surfaces if is_pre_fleuri(gs))
+
+ # Breakdown by green surface typology
+ type_map = {}
+ for gs in green_surfaces:
+ m_id = gs.model_id or 0
+ if m_id not in type_map:
+ m_name = gs.model.get_name() if getattr(gs, 'model', None) else str(_("Autre / Non défini"))
+ m_color = (gs.model.color if (getattr(gs, 'model', None) and gs.model.color) else '#4caf50')
+ type_map[m_id] = {
+ 'name': m_name,
+ 'color': m_color,
+ 'count': 0,
+ 'total_area': 0.0,
+ }
+ type_map[m_id]['count'] += 1
+ type_map[m_id]['total_area'] += (gs.surface_area or 0.0)
+
+ surfaces_by_type = sorted(type_map.values(), key=lambda x: x['total_area'], reverse=True)
+ for item in surfaces_by_type:
+ item['percent'] = round((item['total_area'] / total_green_surface * 100), 1) if total_green_surface > 0 else 0
+
+ shrubs_count = len(shrubs)
+ benches_count = len(benches)
+ total_assets_count = trees_count + green_surfaces_count + shrubs_count + benches_count
+
+ # 3. Interventions linked to location + linked to assets on this location
+ location_intervention_ids = set(InterventionLocation.objects.filter(
+ content_type=content_type, object_id=section.id
+ ).values_list('intervention_id', flat=True))
+
+ tree_ids = [t.id for t in trees]
+ gs_ids = [gs.id for gs in green_surfaces]
+ shrub_ids = [s.id for s in shrubs]
+ bench_ids = [b.id for b in benches]
+
+ asset_tuples = [
+ (ContentType.objects.get_for_model(NatureTree), tree_ids),
+ (ContentType.objects.get_for_model(NatureGreenSurface), gs_ids),
+ (ContentType.objects.get_for_model(NatureShrub), shrub_ids),
+ (ContentType.objects.get_for_model(NatureBench), bench_ids),
+ ]
+ for ct, ids in asset_tuples:
+ if ids:
+ int_ids = InterventionAsset.objects.filter(
+ content_type=ct, object_id__in=ids
+ ).values_list('intervention_id', flat=True)
+ location_intervention_ids.update(int_ids)
+
+ interventions = list(Intervention.objects.filter(
+ id__in=location_intervention_ids
+ ).select_related('contract').order_by('-creation_time'))
+
+ # 4. Documents
+ documents = []
+ try:
+ from documents.models import DocumentAttachment
+ attachments = DocumentAttachment.objects.filter(
+ content_type=content_type, object_id=section.id
+ ).select_related('document').prefetch_related('document__versions')
+ for att in attachments:
+ doc = att.document
+ latest_version = doc.latest_version if hasattr(doc, 'latest_version') else None
+ documents.append({
+ 'title': doc.title or getattr(latest_version, 'filename', str(doc)),
+ 'date': doc.created_at,
+ 'tag': doc.tag.name_fr if (hasattr(doc, 'tag') and doc.tag) else '',
+ 'url': latest_version.file.url if (latest_version and latest_version.file) else None,
+ 'thumbnail_url': latest_version.thumbnail.url if (latest_version and getattr(latest_version, 'thumbnail', None)) else None,
+ })
+ except Exception:
+ pass
+
+ if hasattr(section, 'documents'):
+ for doc in section.documents.all():
+ documents.append({
+ 'title': doc.file.name if hasattr(doc, 'file') and doc.file else str(doc),
+ 'date': getattr(doc, 'upload_date', None),
+ 'tag': '',
+ 'url': doc.file.url if (hasattr(doc, 'file') and doc.file) else None,
+ 'thumbnail_url': doc.thumbnail.url if (hasattr(doc, 'thumbnail') and doc.thumbnail) else None,
+ })
+
+ # 5. Serialized GeoJSON for map display
+ assets_features = []
+
+ for gs in green_surfaces:
+ geom = None
+ if gs.geojson:
+ try:
+ geom = json.loads(gs.geojson)
+ except Exception:
+ geom = None
+ elif gs.geom:
+ try:
+ geom = json.loads(gs.geom.transform(4326, clone=True).geojson)
+ except Exception:
+ geom = None
+
+ if geom:
+ assets_features.append({
+ "type": "Feature",
+ "id": f"gs_{gs.id}",
+ "geometry": geom,
+ "properties": {
+ "id": gs.id,
+ "code": gs.code,
+ "name": gs.get_name(),
+ "model": gs.model.get_name() if gs.model else '',
+ "asset_type": "naturegreensurface",
+ "area": f"{gs.surface_area:.1f} m²" if gs.surface_area else '',
+ "color": gs.model.color if (gs.model and gs.model.color) else '#4caf50',
+ "status": gs.get_status_display(),
+ "lon": gs.lon,
+ "lat": gs.lat,
+ "detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturegreensurface', 'asset_id': gs.id}),
+ }
+ })
+
+ for tree in trees:
+ geom = None
+ if tree.geojson:
+ try:
+ geom = json.loads(tree.geojson)
+ except Exception:
+ geom = None
+ elif tree.lon is not None and tree.lat is not None:
+ geom = {"type": "Point", "coordinates": [tree.lon, tree.lat]}
+
+ if geom:
+ assets_features.append({
+ "type": "Feature",
+ "id": f"tree_{tree.id}",
+ "geometry": geom,
+ "properties": {
+ "id": tree.id,
+ "code": tree.code,
+ "name": tree.get_name() or (tree.model.get_name() if tree.model else ''),
+ "model": tree.model.get_name() if tree.model else '',
+ "scientific_name": tree.model.scientific_name if (tree.model and tree.model.scientific_name) else '',
+ "asset_type": "naturetree",
+ "color": "#2e7d32",
+ "vitality": tree.get_vitality_display() if tree.vitality else '',
+ "stage": tree.get_development_stage_display() if tree.development_stage else '',
+ "height": f"{tree.height} m" if tree.height else '',
+ "status": tree.get_status_display(),
+ "is_remarkable": tree.is_remarkable,
+ "lon": tree.lon,
+ "lat": tree.lat,
+ "detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturetree', 'asset_id': tree.id}),
+ }
+ })
+
+ for shrub in shrubs:
+ geom = None
+ if shrub.geojson:
+ try:
+ geom = json.loads(shrub.geojson)
+ except Exception:
+ geom = None
+ elif shrub.lon is not None and shrub.lat is not None:
+ geom = {"type": "Point", "coordinates": [shrub.lon, shrub.lat]}
+
+ if geom:
+ assets_features.append({
+ "type": "Feature",
+ "id": f"shrub_{shrub.id}",
+ "geometry": geom,
+ "properties": {
+ "id": shrub.id,
+ "code": shrub.code,
+ "model": shrub.model.get_name() if shrub.model else '',
+ "asset_type": "natureshrub",
+ "color": "#81c784",
+ "height": f"{shrub.height} m" if shrub.height else '',
+ "status": shrub.get_status_display(),
+ "lon": shrub.lon,
+ "lat": shrub.lat,
+ "detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'natureshrub', 'asset_id': shrub.id}),
+ }
+ })
+
+ for bench in benches:
+ geom = None
+ if bench.geojson:
+ try:
+ geom = json.loads(bench.geojson)
+ except Exception:
+ geom = None
+ elif bench.lon is not None and bench.lat is not None:
+ geom = {"type": "Point", "coordinates": [bench.lon, bench.lat]}
+
+ if geom:
+ assets_features.append({
+ "type": "Feature",
+ "id": f"bench_{bench.id}",
+ "geometry": geom,
+ "properties": {
+ "id": bench.id,
+ "code": bench.code,
+ "model": bench.model.get_name() if bench.model else '',
+ "asset_type": "naturebench",
+ "color": "#8d6e63",
+ "material": bench.material or '',
+ "status": bench.get_status_display(),
+ "lon": bench.lon,
+ "lat": bench.lat,
+ "detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturebench', 'asset_id': bench.id}),
+ }
+ })
+
+ assets_geojson = json.dumps({"type": "FeatureCollection", "features": assets_features})
+
+ # Location geometry for map
+ section_geojson = section.geojson
+ if not section_geojson and section.geom:
+ try:
+ section_geojson = section.geom.transform(4326, clone=True).geojson
+ except Exception:
+ section_geojson = None
+
+ feature_lon = section.lon
+ feature_lat = section.lat
+ if (feature_lon is None or feature_lat is None) and section.geom:
+ try:
+ pt = section.geom.transform(4326, clone=True).centroid
+ feature_lon = pt.x
+ feature_lat = pt.y
+ except Exception:
+ pass
context = {
'section': section,
+ 'trees': trees,
+ 'green_surfaces': green_surfaces,
+ 'shrubs': shrubs,
+ 'benches': benches,
+ 'trees_count': trees_count,
+ 'trees_active_count': trees_active_count,
+ 'trees_remarkable_count': trees_remarkable_count,
+ 'green_surfaces_count': green_surfaces_count,
+ 'total_green_surface': total_green_surface,
+ 'surface_massif_arbore': surface_massif_arbore,
+ 'massif_arbore_count': massif_arbore_count,
+ 'surface_pre_fleuri': surface_pre_fleuri,
+ 'pre_fleuri_count': pre_fleuri_count,
+ 'surfaces_by_type': surfaces_by_type,
+ 'shrubs_count': shrubs_count,
+ 'benches_count': benches_count,
+ 'total_assets_count': total_assets_count,
'interventions': interventions,
+ 'documents': documents,
+ 'assets_geojson': assets_geojson,
+ 'section_geojson': section_geojson,
+ 'feature_lon': feature_lon,
+ 'feature_lat': feature_lat,
}
return render(request, "assets/nature_locations_detail.html", context)