diff --git a/loko/assets/static/assets/nature_location_detail.js b/loko/assets/static/assets/nature_location_detail.js new file mode 100644 index 0000000..66c3215 --- /dev/null +++ b/loko/assets/static/assets/nature_location_detail.js @@ -0,0 +1,309 @@ +/** + * Visualisation cartographique détaillée d'une localisation Nature (StreetUp / Loko) + */ +document.addEventListener('DOMContentLoaded', function () { + const mapContainer = document.getElementById('map'); + if (!mapContainer) return; + + const lon = typeof feature_lon !== 'undefined' && feature_lon !== null ? feature_lon : 4.3517; + const lat = typeof feature_lat !== 'undefined' && feature_lat !== null ? feature_lat : 50.8503; + + const map = new maplibregl.Map({ + container: 'map', + style: window.MAP_STYLE_LIGHT || 'https://api.maptiler.com/maps/streets-v2-light/style.json', + center: [lon, lat], + zoom: 16, + pitch: 35, + bearing: -10, + antialias: true + }); + + // Add navigation and fullscreen controls + map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right'); + map.addControl(new maplibregl.FullscreenControl(), 'top-right'); + + map.on('load', function () { + let combinedBbox = null; + + // 1. Location geometry (polygon / boundary) + if (typeof section_geojson !== 'undefined' && section_geojson) { + const locGeom = typeof section_geojson === 'string' ? JSON.parse(section_geojson) : section_geojson; + const locationFeature = { + type: 'Feature', + geometry: locGeom, + properties: { + name: 'Location' + } + }; + + map.addSource('nature-location', { + type: 'geojson', + data: locationFeature + }); + + // Fill layer for the section + map.addLayer({ + id: 'nature-location-fill', + type: 'fill', + source: 'nature-location', + paint: { + 'fill-color': '#16a34a', + 'fill-opacity': 0.15 + } + }); + + // Outline layer for the section + map.addLayer({ + id: 'nature-location-outline', + type: 'line', + source: 'nature-location', + paint: { + 'line-color': '#15803d', + 'line-width': 2.5 + } + }); + + if (typeof turf !== 'undefined') { + try { + combinedBbox = turf.bbox(locationFeature); + } catch (e) { + console.warn('Could not compute location bbox:', e); + } + } + } + + // 2. Assets (green surfaces, trees, shrubs, benches) + if (typeof assets_geojson !== 'undefined' && assets_geojson) { + const assetsData = typeof assets_geojson === 'string' ? JSON.parse(assets_geojson) : assets_geojson; + + map.addSource('nature-assets', { + type: 'geojson', + data: assetsData + }); + + // Green surfaces fill + map.addLayer({ + id: 'layer-surfaces-fill', + type: 'fill', + source: 'nature-assets', + filter: ['==', ['get', 'asset_type'], 'naturegreensurface'], + paint: { + 'fill-color': ['coalesce', ['get', 'color'], '#22c55e'], + 'fill-opacity': 0.65 + } + }); + + // Green surfaces outline + map.addLayer({ + id: 'layer-surfaces-outline', + type: 'line', + source: 'nature-assets', + filter: ['==', ['get', 'asset_type'], 'naturegreensurface'], + paint: { + 'line-color': '#166534', + 'line-width': 1.5 + } + }); + + // Trees (circles) + map.addLayer({ + id: 'layer-trees', + type: 'circle', + source: 'nature-assets', + filter: ['==', ['get', 'asset_type'], 'naturetree'], + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 13, 4, + 16, 7, + 19, 12 + ], + 'circle-color': '#15803d', + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff' + } + }); + + // Shrubs (circles) + map.addLayer({ + id: 'layer-shrubs', + type: 'circle', + source: 'nature-assets', + filter: ['==', ['get', 'asset_type'], 'natureshrub'], + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 13, 3, + 16, 5, + 19, 9 + ], + 'circle-color': '#84cc16', + 'circle-stroke-width': 1.5, + 'circle-stroke-color': '#ffffff' + } + }); + + // Benches (circles) + map.addLayer({ + id: 'layer-benches', + type: 'circle', + source: 'nature-assets', + filter: ['==', ['get', 'asset_type'], 'naturebench'], + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 13, 3, + 16, 5, + 19, 9 + ], + 'circle-color': '#b45309', + 'circle-stroke-width': 1.5, + 'circle-stroke-color': '#ffffff' + } + }); + + // Fit bounds with Turf if features exist + if (typeof turf !== 'undefined' && assetsData.features && assetsData.features.length > 0) { + try { + const assetsBbox = turf.bbox(assetsData); + if (!combinedBbox) { + combinedBbox = assetsBbox; + } else { + combinedBbox = [ + Math.min(combinedBbox[0], assetsBbox[0]), + Math.min(combinedBbox[1], assetsBbox[1]), + Math.max(combinedBbox[2], assetsBbox[2]), + Math.max(combinedBbox[3], assetsBbox[3]) + ]; + } + } catch (e) { + console.warn('Could not compute assets bbox:', e); + } + } + } + + // Apply fit bounds + if (combinedBbox && combinedBbox[0] !== Infinity) { + map.fitBounds(combinedBbox, { + padding: { top: 40, bottom: 40, left: 40, right: 40 }, + maxZoom: 18, + duration: 800 + }); + } + + // 3. Interactive Popups + const interactiveLayers = [ + 'layer-surfaces-fill', + 'layer-trees', + 'layer-shrubs', + 'layer-benches' + ]; + + interactiveLayers.forEach(function (layerId) { + map.on('mouseenter', layerId, function () { + map.getCanvas().style.cursor = 'pointer'; + }); + + map.on('mouseleave', layerId, function () { + map.getCanvas().style.cursor = ''; + }); + + map.on('click', layerId, function (e) { + if (!e.features || !e.features.length) return; + const f = e.features[0]; + const props = f.properties; + + let iconClass = 'bi-tree-fill text-success'; + let typeBadge = 'Surface'; + + if (props.asset_type === 'naturetree') { + iconClass = 'bi-tree-fill text-success'; + typeBadge = 'Arbre'; + } else if (props.asset_type === 'natureshrub') { + iconClass = 'bi-flower3 text-success'; + typeBadge = 'Arbuste'; + } else if (props.asset_type === 'naturebench') { + iconClass = 'bi-inbox text-warning'; + typeBadge = 'Banc / Mobilier'; + } else if (props.asset_type === 'naturegreensurface') { + iconClass = 'bi-grid-fill text-success'; + typeBadge = 'Surface verte'; + } + + let detailsHtml = ''; + if (props.area) { + detailsHtml += `
Superficie : ${props.area}
`; + } + if (props.vitality) { + detailsHtml += `
Vitalité : ${props.vitality}
`; + } + if (props.stage) { + detailsHtml += `
Stade : ${props.stage}
`; + } + if (props.height) { + detailsHtml += `
Hauteur : ${props.height}
`; + } + if (props.is_remarkable === 'true' || props.is_remarkable === true) { + detailsHtml += `
Arbre remarquable
`; + } + + const popupHtml = ` +
+
+ ${props.code || ''} + ${typeBadge} +
+
+ ${props.model ? `
${props.model}
` : ''} + ${props.scientific_name ? `
${props.scientific_name}
` : ''} + ${props.status ? `
${props.status}
` : ''} + ${detailsHtml} +
+ ${props.detail_url ? ` + + Consulter la fiche + + ` : ''} +
+ `; + + new maplibregl.Popup({ closeButton: true, closeOnClick: true, maxWidth: '280px' }) + .setLngLat(e.lngLat) + .setHTML(popupHtml) + .addTo(map); + }); + }); + + // 4. Layer Toggle Handlers + window.toggleNatureLayer = function (layerGroup, isChecked) { + const layerMapping = { + 'surfaces': ['layer-surfaces-fill', 'layer-surfaces-outline'], + 'trees': ['layer-trees'], + 'shrubs': ['layer-shrubs'], + 'benches': ['layer-benches'] + }; + + const layers = layerMapping[layerGroup]; + if (layers) { + const visibility = isChecked ? 'visible' : 'none'; + layers.forEach(function (id) { + if (map.getLayer(id)) { + map.setLayoutProperty(id, 'visibility', visibility); + } + }); + } + }; + + // 5. Global Zoom to Asset helper + window.zoomToAsset = function (assetLon, assetLat, assetCode) { + if (assetLon && assetLat) { + map.flyTo({ + center: [assetLon, assetLat], + zoom: 18.5, + pitch: 45, + duration: 1000 + }); + } + }; + }); +}); diff --git a/loko/assets/templates/assets/nature_locations_detail.html b/loko/assets/templates/assets/nature_locations_detail.html index 66f3f38..ea824a8 100644 --- a/loko/assets/templates/assets/nature_locations_detail.html +++ b/loko/assets/templates/assets/nature_locations_detail.html @@ -2,116 +2,704 @@ {% load static %} {% load i18n %} -{% block content %} -
-
-

- {{ section.code}} - {{ section.name_fr}} -

-
-
-
- {{ section.zone|default:"" }}
- {{ section.is_highway|default:"" }}
-
-
- {% if section.lon %} - - {% translate 'Street View' %} - {% translate "View in Google Street View" %} - - {% endif %} -
-
-
-
-
-
-
-
-
+{% block extra_head %} + +{% endblock extra_head %} - -
-
-

{% translate "Documents" %}

-
-
-
- {% for document in section.documents.all %} -
- -
- {% if document.thumbnail %} - {{ document.file.name }} - {% else %} - {{ document.file.name }} +{% 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 %}
- -
-
-

{% translate "Interventions" %}

+ +
+
+
+
+ +
+
+
{% translate "Surfaces végétales:" %}
+
{{ total_green_surface|floatformat:1 }} m²
+
+ {{ green_surfaces_count }} {% translate "surfaces au total" %} +
+
+
-
- - - - - - - - - - - {% for interv in interventions %} + + + +
+
+
+
+ +
+
+
{% translate "Superficie massif arboré:" %}
+
{{ surface_massif_arbore|floatformat:1 }} m²
+
+ {{ massif_arbore_count }} {% translate "massifs arborés" %} +
+
+
+
+
+ + +
+
+
+
+ +
+
+
{% translate "Superficie pré fleuri:" %}
+
{{ surface_pre_fleuri|floatformat:1 }} m²
+
+ {{ pre_fleuri_count }} {% translate "prés fleuris" %} +
+
+
+
+
+ + + + {% if surfaces_by_type %} +
+
+
+ {% translate "Répartition des surfaces végétales" %} + {{ total_green_surface|floatformat:1 }} m² +
+ +
+ {% for st in surfaces_by_type %} +
+ {% endfor %} +
+ +
+ {% for st in surfaces_by_type %} + + + {{ st.name }}: + {{ st.total_area|floatformat:1 }} m² ({{ st.count }}) + + {% endfor %} +
+
+
+ {% endif %} + + +
+ +
+ + + + +
+ + +
+
+
{% translate "Code" %}{% translate "Titre" %}{% translate "Début" %}{% translate "Statut" %}
+ + + + + + + + + + + + {% for gs in green_surfaces %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Code" %}{% translate "Typologie / Modèle" %}{% translate "Superficie" %}{% translate "Pente / Tonte" %}{% translate "Bulbes" %}{% translate "Statut" %}{% translate "Actions" %}
+ + {{ gs.code }} + + - + + {{ gs.model.get_name|default:"-" }} + + {% if gs.surface_area %}{{ gs.surface_area|floatformat:1 }} m²{% else %}-{% endif %} + + + {% if gs.slope %}{{ gs.slope }}%{% else %}-{% endif %} / + {% if gs.mower_frenquency %}{{ gs.mower_frenquency }}x/an{% else %}-{% endif %} + + + {% if gs.bulb_types %} + {{ gs.bulb_types|length }} bulbe(s) + {% else %} + - + {% endif %} + + + {{ gs.get_status_display }} + + +
+ {% if gs.lon and gs.lat %} + + {% endif %} + + + +
+
+ + {% translate "Aucune surface végétalisée enregistrée sur cette voirie." %} +
+
+
+ + +
+
+ + + + + + + + + + + + + + {% for tree in trees %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Code" %}{% translate "Essence / Modèle" %}{% translate "Stade" %}{% translate "Vitalité" %}{% translate "Risque" %}{% translate "Statut" %}{% translate "Actions" %}
+ + {{ tree.code }} + + {% if tree.is_remarkable %} + + {% endif %} + +
{{ tree.model.get_name|default:"-" }}
+ {% if tree.model.scientific_name %} +
{{ tree.model.scientific_name }}
+ {% endif %} +
+ {{ tree.get_development_stage_display|default:"-" }} + + {% if tree.vitality == 'strong' %} + {% translate "Forte" %} + {% elif tree.vitality == 'normal' %} + {% translate "Normale" %} + {% elif tree.vitality == 'weak' %} + {% translate "Faible" %} + {% elif tree.vitality == 'declining' or tree.vitality == 'dead' %} + {{ tree.get_vitality_display }} + {% else %} + - + {% endif %} + + {% if tree.risk_level == 'high' or tree.risk_level == 'imminent_danger' %} + {{ tree.get_risk_level_display }} + {% elif tree.risk_level == 'moderate' %} + {{ tree.get_risk_level_display }} + {% else %} + {{ tree.get_risk_level_display|default:"Faible" }} + {% endif %} + + + {{ tree.get_status_display }} + + +
+ {% if tree.lon and tree.lat %} + + {% endif %} + + + +
+
+ + {% translate "Aucun arbre enregistré sur cette voirie." %} +
+
+
+ + +
+ +
{% translate "Arbustes" %} ({{ shrubs_count }})
+
+ + + + + + + + + + + + + {% for shrub in shrubs %} + + + + + + + + + {% empty %} + + {% endfor %} + +
{% translate "Code" %}{% translate "Modèle" %}{% translate "Hauteur" %}{% translate "Taillé" %}{% translate "Statut" %}{% translate "Actions" %}
+ + {{ shrub.code }} + + {{ shrub.model.get_name|default:"-" }}{% if shrub.height %}{{ shrub.height }} m{% else %}-{% endif %} + {% if shrub.trimmed %}{% translate "Oui" %}{% else %}{% translate "Non" %}{% endif %} + {{ shrub.get_status_display }} +
+ {% if shrub.lon and shrub.lat %} + + {% endif %} + + + +
+
{% translate "Aucun arbuste répertorié." %}
+
+ + +
{% translate "Mobilier & Bancs" %} ({{ benches_count }})
+
+ + + + + + + + + + + + + {% for bench in benches %} + + + + + + + + + {% empty %} + + {% endfor %} + +
{% translate "Code" %}{% translate "Modèle" %}{% translate "Matériau" %}{% translate "Dimensions" %}{% translate "Statut" %}{% translate "Actions" %}
+ + {{ bench.code }} + + {{ bench.model.get_name|default:"-" }}{{ bench.material|default:"-" }} + {% if bench.length or bench.width or bench.height %} + {{ bench.length|default:"-" }} x {{ bench.width|default:"-" }} x {{ bench.height|default:"-" }} m + {% else %} + - + {% endif %} + {{ bench.get_status_display }} +
+ {% if bench.lon and bench.lat %} + + {% endif %} + + + +
+
{% translate "Aucun banc ou mobilier répertorié." %}
+
+
+ + +
+
+ + + + + + + + + + + + {% for interv in interventions %} + + - + - - + + - {% endfor %} - - -
{% translate "Code" %}{% translate "Titre" %}{% translate "Début" %}{% translate "Statut" %}
+ {{ interv.code }}{{ interv.code }} {{ interv.title }}{{ interv.begin_time|date:"d-m-Y H:i" }}{{ interv.get_status_display }}{{ interv.begin_time|date:"d-m-Y H:i"|default:"-" }} + {{ interv.get_status_display }} +
+ {% empty %} + + + + {% translate "Aucune intervention enregistrée sur cette localisation ou ses aménagements." %} + + + {% endfor %} + + +
+
+ + +
+ {% if documents %} + + {% else %} +
+ + {% translate "Aucun document associé à cette localisation." %} +
+ {% endif %} +
+ +
+
+ + +
+
+
+ + {% translate "Carte & Aménagements" %} + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
-
-
-
-
- -{% 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)