feat: implement nature location detail view with asset summaries, KPIs, and unit tests

This commit is contained in:
kdeterme 2026-10-05 13:44:18 +02:00
parent f324eb00bd
commit 11e1674f73
4 changed files with 1431 additions and 88 deletions

View file

@ -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 = '<span class="badge bg-success">Surface</span>';
if (props.asset_type === 'naturetree') {
iconClass = 'bi-tree-fill text-success';
typeBadge = '<span class="badge bg-success">Arbre</span>';
} else if (props.asset_type === 'natureshrub') {
iconClass = 'bi-flower3 text-success';
typeBadge = '<span class="badge bg-info">Arbuste</span>';
} else if (props.asset_type === 'naturebench') {
iconClass = 'bi-inbox text-warning';
typeBadge = '<span class="badge bg-warning text-dark">Banc / Mobilier</span>';
} else if (props.asset_type === 'naturegreensurface') {
iconClass = 'bi-grid-fill text-success';
typeBadge = '<span class="badge bg-primary">Surface verte</span>';
}
let detailsHtml = '';
if (props.area) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-rulers me-1"></i>Superficie : <strong>${props.area}</strong></div>`;
}
if (props.vitality) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-heart-pulse me-1"></i>Vitalité : <strong>${props.vitality}</strong></div>`;
}
if (props.stage) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-clock-history me-1"></i>Stade : <strong>${props.stage}</strong></div>`;
}
if (props.height) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-arrows-vertical me-1"></i>Hauteur : <strong>${props.height}</strong></div>`;
}
if (props.is_remarkable === 'true' || props.is_remarkable === true) {
detailsHtml += `<div class="badge bg-warning text-dark my-1"><i class="bi bi-star-fill me-1"></i>Arbre remarquable</div>`;
}
const popupHtml = `
<div class="nature-map-popup" style="min-width: 200px; padding: 4px;">
<div class="d-flex justify-content-between align-items-center mb-1 pb-1 border-bottom">
<span class="fw-bold"><i class="bi ${iconClass} me-1"></i>${props.code || ''}</span>
${typeBadge}
</div>
<div class="mb-2">
${props.model ? `<div class="fw-semibold text-dark">${props.model}</div>` : ''}
${props.scientific_name ? `<div class="fst-italic text-muted small">${props.scientific_name}</div>` : ''}
${props.status ? `<div class="small"><span class="badge bg-light text-secondary border mt-1">${props.status}</span></div>` : ''}
${detailsHtml}
</div>
${props.detail_url ? `
<a href="${props.detail_url}" class="btn btn-xs btn-primary w-100 text-center py-1" style="font-size: 0.8rem;">
<i class="bi bi-arrow-right-circle me-1"></i>Consulter la fiche
</a>
` : ''}
</div>
`;
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
});
}
};
});
});

View file

@ -2,116 +2,704 @@
{% load static %}
{% load i18n %}
{% block content %}
<div class="row mt-2" style="height: calc(100vh - 200px);">
<div class="col-md-8" style="max-height: calc(100vh - 200px); overflow: auto;">
<h3 class="h3">
<i class="bi bi-suit-club"></i><span class="mx-2">{{ section.code}} - {{ section.name_fr}}</span>
</h3>
<hr>
<div class="row mb-4">
<div class="col-md-6">
<label class="mx-2">{% translate "Zone:" %}</label><i class="bi bi-circle-fill status_{{ surface.status }}"></i> {{ section.zone|default:"" }}<br>
<label class="mx-2">{% translate "Autoroute:" %}</label>{{ section.is_highway|default:"" }}<br>
</div>
<div class="col-md-6">
{% if section.lon %}
<a href="https://www.google.com/maps?cbll={{ section.lat|floatformat:'6u'}},{{ section.lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid=" target="_blank" style="text-decoration: none; font-size: 16px; display: flex; align-items: center;">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="{% translate 'Street View' %}" height="20" style="margin-right: 8px;">
{% translate "View in Google Street View" %}
</a>
{% endif %}
</div>
</div>
<div class="row mb-4">
<div class="col-md-12">
<label class="mx-2">{% translate "Nombre d'arbres:" %}</label><br>
<label class="mx-2">{% translate "Superficie massif arboré:" %}</label><br>
<label class="mx-2">{% translate "Superficie pré fleuri:" %}</label><br>
</div>
</div>
{% block extra_head %}
<style>
.nature-kpi-card {
border-radius: 10px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
border: 1px solid #e2e8f0;
background: #ffffff;
}
.nature-kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
.nature-kpi-icon {
width: 44px;
height: 44px;
min-width: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.nature-tab-content {
background: #ffffff;
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 1rem;
}
#map {
width: 100%;
height: calc(100vh - 230px);
min-height: 520px;
border-radius: 8px;
}
.map-card-wrapper {
position: sticky;
top: 1rem;
}
.map-layer-pill {
font-size: 0.78rem;
cursor: pointer;
user-select: none;
}
</style>
{% endblock extra_head %}
<!-- Documents -->
<div class="card mb-4">
<div class="card-header">
<h4>{% translate "Documents" %}</h4>
</div>
<div class="card-body">
<div class="row">
{% for document in section.documents.all %}
<div class="col-md-2 mb-3">
<a href="{{ document.file.url }}" target="_blank">
<div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
{% if document.thumbnail %}
<img src="{{ document.thumbnail.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% else %}
<img src="{{ document.file.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% block content %}
<div class="container-fluid py-3">
<!-- Breadcrumb & Navigation -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{% url 'assets:nature_index' %}" class="text-decoration-none">{% translate "Nature" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:nature_locations' %}" class="text-decoration-none">{% translate "Voiries" %}</a></li>
<li class="breadcrumb-item active fw-semibold" aria-current="page">{{ section.code }}</li>
</ol>
</nav>
<div class="d-flex gap-2">
<a href="{% url 'assets:nature_index' %}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la carte" %}
</a>
{% if section.lon and section.lat %}
<a href="https://www.google.com/maps?cbll={{ section.lat|floatformat:'6u'}},{{ section.lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid="
target="_blank" class="btn btn-sm btn-outline-primary" style="text-decoration: none;">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="{% translate 'Street View' %}" height="18" class="me-1">
{% translate "View in Google Street View" %}
</a>
{% endif %}
</div>
</div>
<!-- Header Card -->
<div class="card shadow-sm mb-4 border-0" style="background: linear-gradient(to right, #f8fafc, #ffffff); border-left: 5px solid #16a34a !important;">
<div class="card-body p-3 p-md-4">
<div class="row align-items-center g-3">
<div class="col-lg-8">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-success-subtle p-2 d-flex align-items-center justify-content-center text-success" style="width: 52px; height: 52px; min-width: 52px;">
<i class="bi bi-tree-fill" style="font-size: 1.8rem;"></i>
</div>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<h1 class="h4 mb-0 fw-bold text-dark">
{{ section.code }} <span class="text-muted fw-normal">&mdash; {{ section.get_name }}</span>
</h1>
</div>
<div class="d-flex align-items-center gap-2 mt-2 flex-wrap text-muted small">
{% if section.municipality %}
<span><i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ section.municipality }}</span>
<span>&bull;</span>
{% endif %}
<span>
<strong>{% translate "Zone:" %}</strong>
{% if section.zone %}
<span class="badge bg-secondary-subtle text-secondary border">Zone {{ section.zone }}</span>
{% else %}
<span class="text-muted">{% translate "Non définie" %}</span>
{% endif %}
</span>
<span>&bull;</span>
<span>
<strong>{% translate "Autoroute:" %}</strong>
{% if section.is_highway %}
<span class="badge bg-primary-subtle text-primary border">{% translate "Oui" %}</span>
{% else %}
<span class="badge bg-light text-secondary border">{% translate "Non" %}</span>
{% endif %}
</span>
{% if section.category %}
<span>&bull;</span>
<span><i class="bi bi-tag me-1"></i>{{ section.category.get_name }}</span>
{% endif %}
</div>
</a>
</div>
</div>
{% if section.parent_location or section.child_locations.exists %}
<div class="mt-2 pt-2 border-top d-flex gap-3 flex-wrap small text-muted">
{% if section.parent_location %}
<div>
<i class="bi bi-diagram-2 me-1"></i>{% translate "Section parente :" %}
<a href="{{ section.parent_location.get_absolute_url }}" class="fw-semibold text-decoration-none">
{{ section.parent_location.code }} - {{ section.parent_location.get_name }}
</a>
</div>
{% endif %}
{% if section.child_locations.exists %}
<div>
<i class="bi bi-diagram-3 me-1"></i>{% translate "Sous-sections :" %}
{% for child in section.child_locations.all %}
<a href="{{ child.get_absolute_url }}" class="badge bg-light text-dark border text-decoration-none me-1">
{{ child.code }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="col-lg-4 text-lg-end">
<div class="d-inline-flex gap-2 flex-wrap justify-content-lg-end">
<div class="badge bg-light text-dark border p-2 text-start">
<span class="d-block text-muted" style="font-size: 0.72rem;">{% translate "Total équipements" %}</span>
<span class="fw-bold fs-6">{{ total_assets_count }}</span>
</div>
<div class="badge bg-light text-dark border p-2 text-start">
<span class="d-block text-muted" style="font-size: 0.72rem;">{% translate "Interventions" %}</span>
<span class="fw-bold fs-6 text-primary">{{ interventions|length }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- KPI Metric Cards Grid -->
<div class="row g-3 mb-4">
<!-- 1. Nombre d'arbres -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-success-subtle text-success">
<i class="bi bi-tree-fill"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Nombre d'arbres:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ trees_count }}</div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ trees_active_count }} {% translate "actifs" %}
{% if trees_remarkable_count %}
&bull; <span class="text-warning fw-semibold"><i class="bi bi-star-fill text-warning"></i> {{ trees_remarkable_count }} {% translate "remarquables" %}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Interventions -->
<div class="card mb-4">
<div class="card-header">
<h4>{% translate "Interventions" %}</h4>
<!-- 2. Superficie totale surfaces vertes -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-primary-subtle text-primary">
<i class="bi bi-grid-fill"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Surfaces végétales:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ total_green_surface|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ green_surfaces_count }} {% translate "surfaces au total" %}
</div>
</div>
</div>
</div>
<div class="card-body">
<table id="interventions-table" class="table"
data-height="550"
data-pagination="true"
data-page-size="25"
data-click-to-select="true" >
<thead>
<tr>
<th data-field="actions" data-width="100"></th>
<th data-field="code">{% translate "Code" %}</th>
<th data-field="title">{% translate "Titre" %}</th>
<th data-field="begin_date">{% translate "Début" %}</th>
<th data-field="statut">{% translate "Statut" %}</th>
</tr>
<tbody>
{% for interv in interventions %}
</div>
<!-- 3. Superficie massif arboré -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-warning-subtle text-warning-emphasis">
<i class="bi bi-flower1"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Superficie massif arboré:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ surface_massif_arbore|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ massif_arbore_count }} {% translate "massifs arborés" %}
</div>
</div>
</div>
</div>
</div>
<!-- 4. Superficie pré fleuri -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-info-subtle text-info-emphasis">
<i class="bi bi-flower2"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Superficie pré fleuri:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ surface_pre_fleuri|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ pre_fleuri_count }} {% translate "prés fleuris" %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Typology breakdown (if available) -->
{% if surfaces_by_type %}
<div class="card shadow-sm mb-4 border-0">
<div class="card-body p-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="small fw-bold text-secondary text-uppercase"><i class="bi bi-pie-chart-fill me-1"></i>{% translate "Répartition des surfaces végétales" %}</span>
<span class="small text-muted">{{ total_green_surface|floatformat:1 }} m²</span>
</div>
<!-- Progress bar -->
<div class="progress mb-2" style="height: 10px; border-radius: 6px;">
{% for st in surfaces_by_type %}
<div class="progress-bar" role="progressbar"
style="width: {{ st.percent }}%; background-color: {{ st.color }};"
title="{{ st.name }}: {{ st.total_area|floatformat:1 }} m² ({{ st.percent }}%)"
aria-valuenow="{{ st.percent }}" aria-valuemin="0" aria-valuemax="100"></div>
{% endfor %}
</div>
<!-- Badges -->
<div class="d-flex flex-wrap gap-2 mt-2">
{% for st in surfaces_by_type %}
<span class="badge bg-light text-dark border d-inline-flex align-items-center gap-1 py-1 px-2" style="font-size: 0.8rem;">
<span class="d-inline-block rounded-circle" style="width: 8px; height: 8px; background-color: {{ st.color }};"></span>
<span class="fw-semibold">{{ st.name }}</span>:
<span class="text-muted">{{ st.total_area|floatformat:1 }} m² ({{ st.count }})</span>
</span>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Main Content Layout (Split View: Tabs/Tables on Left, Interactive Map on Right) -->
<div class="row g-4">
<!-- Left Column: Tabs & Tables -->
<div class="col-lg-7 col-xl-7">
<!-- Tabs Navigation -->
<ul class="nav nav-tabs" id="natureLocationTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active fw-semibold" id="surfaces-tab" data-bs-toggle="tab" data-bs-target="#surfaces-pane" type="button" role="tab">
<i class="bi bi-grid-fill text-success me-1"></i>{% translate "Surfaces vertes" %}
<span class="badge bg-success-subtle text-success ms-1">{{ green_surfaces_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="trees-tab" data-bs-toggle="tab" data-bs-target="#trees-pane" type="button" role="tab">
<i class="bi bi-tree-fill text-success me-1"></i>{% translate "Arbres" %}
<span class="badge bg-success-subtle text-success ms-1">{{ trees_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="equipments-tab" data-bs-toggle="tab" data-bs-target="#equipments-pane" type="button" role="tab">
<i class="bi bi-flower3 text-info me-1"></i>{% translate "Arbustes & Bancs" %}
<span class="badge bg-secondary-subtle text-secondary ms-1">{{ shrubs_count|add:benches_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="interventions-tab" data-bs-toggle="tab" data-bs-target="#interventions-pane" type="button" role="tab">
<i class="bi bi-tools text-primary me-1"></i>{% translate "Interventions" %}
<span class="badge bg-primary-subtle text-primary ms-1">{{ interventions|length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents-pane" type="button" role="tab">
<i class="bi bi-files text-secondary me-1"></i>{% translate "Documents" %}
<span class="badge bg-secondary-subtle text-secondary ms-1">{{ documents|length }}</span>
</button>
</li>
</ul>
<!-- Tabs Content -->
<div class="tab-content nature-tab-content shadow-sm" id="natureLocationTabsContent">
<!-- TAB 1: Surfaces végétalisées -->
<div class="tab-pane fade show active" id="surfaces-pane" role="tabpanel" aria-labelledby="surfaces-tab">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Typologie / Modèle" %}</th>
<th class="text-end">{% translate "Superficie" %}</th>
<th>{% translate "Pente / Tonte" %}</th>
<th>{% translate "Bulbes" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for gs in green_surfaces %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturegreensurface' gs.id %}" class="text-decoration-none">
{{ gs.code }}
</a>
</td>
<td>
<a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" type="button" class="btn btn-sm btn-light detail-btn" data-id="${row.id}" data-bs-toggle="tooltip" title="{% translate 'Détail' %}">
<span class="d-inline-block rounded-circle me-1" style="width: 8px; height: 8px; background-color: {{ gs.model.color|default:'#4caf50' }};"></span>
{{ gs.model.get_name|default:"-" }}
</td>
<td class="text-end fw-semibold">
{% if gs.surface_area %}{{ gs.surface_area|floatformat:1 }} m²{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td>
<span class="small text-muted">
{% if gs.slope %}{{ gs.slope }}%{% else %}-{% endif %} /
{% if gs.mower_frenquency %}{{ gs.mower_frenquency }}x/an{% else %}-{% endif %}
</span>
</td>
<td>
{% if gs.bulb_types %}
<span class="badge bg-info-subtle text-info border">{{ gs.bulb_types|length }} bulbe(s)</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<span class="badge {% if gs.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ gs.get_status_display }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if gs.lon and gs.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ gs.lon|stringformat:'f' }}, {{ gs.lat|stringformat:'f' }}, '{{ gs.code }}')" title="{% translate 'Centrer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturegreensurface' gs.id %}" class="btn btn-outline-primary" title="{% translate 'Détail' %}">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-grid mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucune surface végétalisée enregistrée sur cette voirie." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 2: Arbres -->
<div class="tab-pane fade" id="trees-pane" role="tabpanel" aria-labelledby="trees-tab">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Essence / Modèle" %}</th>
<th>{% translate "Stade" %}</th>
<th>{% translate "Vitalité" %}</th>
<th>{% translate "Risque" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for tree in trees %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturetree' tree.id %}" class="text-decoration-none">
{{ tree.code }}
</a>
{% if tree.is_remarkable %}
<i class="bi bi-star-fill text-warning ms-1" title="{% translate 'Arbre remarquable' %}"></i>
{% endif %}
</td>
<td>
<div>{{ tree.model.get_name|default:"-" }}</div>
{% if tree.model.scientific_name %}
<div class="small text-muted fst-italic">{{ tree.model.scientific_name }}</div>
{% endif %}
</td>
<td>
<span class="small">{{ tree.get_development_stage_display|default:"-" }}</span>
</td>
<td>
{% if tree.vitality == 'strong' %}
<span class="badge bg-success-subtle text-success border">{% translate "Forte" %}</span>
{% elif tree.vitality == 'normal' %}
<span class="badge bg-info-subtle text-info border">{% translate "Normale" %}</span>
{% elif tree.vitality == 'weak' %}
<span class="badge bg-warning-subtle text-warning-emphasis border">{% translate "Faible" %}</span>
{% elif tree.vitality == 'declining' or tree.vitality == 'dead' %}
<span class="badge bg-danger">{{ tree.get_vitality_display }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if tree.risk_level == 'high' or tree.risk_level == 'imminent_danger' %}
<span class="badge bg-danger">{{ tree.get_risk_level_display }}</span>
{% elif tree.risk_level == 'moderate' %}
<span class="badge bg-warning text-dark">{{ tree.get_risk_level_display }}</span>
{% else %}
<span class="small text-muted">{{ tree.get_risk_level_display|default:"Faible" }}</span>
{% endif %}
</td>
<td>
<span class="badge {% if tree.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ tree.get_status_display }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if tree.lon and tree.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ tree.lon|stringformat:'f' }}, {{ tree.lat|stringformat:'f' }}, '{{ tree.code }}')" title="{% translate 'Centrer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturetree' tree.id %}" class="btn btn-outline-primary" title="{% translate 'Détail' %}">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-tree mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucun arbre enregistré sur cette voirie." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 3: Arbustes & Bancs -->
<div class="tab-pane fade" id="equipments-pane" role="tabpanel" aria-labelledby="equipments-tab">
<!-- Arbustes -->
<h6 class="fw-bold mb-2 text-dark"><i class="bi bi-flower3 text-success me-1"></i>{% translate "Arbustes" %} ({{ shrubs_count }})</h6>
<div class="table-responsive mb-4">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Hauteur" %}</th>
<th>{% translate "Taillé" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for shrub in shrubs %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'natureshrub' shrub.id %}" class="text-decoration-none">
{{ shrub.code }}
</a>
</td>
<td>{{ shrub.model.get_name|default:"-" }}</td>
<td>{% if shrub.height %}{{ shrub.height }} m{% else %}-{% endif %}</td>
<td>
{% if shrub.trimmed %}<span class="badge bg-success-subtle text-success">{% translate "Oui" %}</span>{% else %}<span class="badge bg-light text-secondary">{% translate "Non" %}</span>{% endif %}
</td>
<td><span class="badge bg-secondary">{{ shrub.get_status_display }}</span></td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if shrub.lon and shrub.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ shrub.lon|stringformat:'f' }}, {{ shrub.lat|stringformat:'f' }}, '{{ shrub.code }}')">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'natureshrub' shrub.id %}" class="btn btn-outline-primary">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-2 small">{% translate "Aucun arbuste répertorié." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Bancs / Mobilier -->
<h6 class="fw-bold mb-2 text-dark"><i class="bi bi-inbox text-warning me-1"></i>{% translate "Mobilier & Bancs" %} ({{ benches_count }})</h6>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Matériau" %}</th>
<th>{% translate "Dimensions" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for bench in benches %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturebench' bench.id %}" class="text-decoration-none">
{{ bench.code }}
</a>
</td>
<td>{{ bench.model.get_name|default:"-" }}</td>
<td>{{ bench.material|default:"-" }}</td>
<td>
{% if bench.length or bench.width or bench.height %}
{{ bench.length|default:"-" }} x {{ bench.width|default:"-" }} x {{ bench.height|default:"-" }} m
{% else %}
-
{% endif %}
</td>
<td><span class="badge bg-secondary">{{ bench.get_status_display }}</span></td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if bench.lon and bench.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ bench.lon|stringformat:'f' }}, {{ bench.lat|stringformat:'f' }}, '{{ bench.code }}')">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturebench' bench.id %}" class="btn btn-outline-primary">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-2 small">{% translate "Aucun banc ou mobilier répertorié." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 4: Interventions -->
<div class="tab-pane fade" id="interventions-pane" role="tabpanel" aria-labelledby="interventions-tab">
<div class="table-responsive">
<table id="interventions-table" class="table table-hover align-middle mb-0"
data-pagination="true"
data-page-size="25">
<thead class="table-light">
<tr>
<th data-field="actions" class="text-center" style="width: 50px;"></th>
<th data-field="code">{% translate "Code" %}</th>
<th data-field="title">{% translate "Titre" %}</th>
<th data-field="begin_date">{% translate "Début" %}</th>
<th data-field="status">{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for interv in interventions %}
<tr>
<td class="text-center">
<a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" class="btn btn-sm btn-light detail-btn" title="{% translate 'Détail' %}">
<i class="bi bi-info-circle"></i>
</a>
</td>
<td>{{ interv.code }}</td>
<td class="fw-bold">{{ interv.code }}</td>
<td>{{ interv.title }}</td>
<td>{{ interv.begin_time|date:"d-m-Y H:i" }}</td>
<td>{{ interv.get_status_display }}</td>
<td>{{ interv.begin_time|date:"d-m-Y H:i"|default:"-" }}</td>
<td>
<span class="badge bg-secondary">{{ interv.get_status_display }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</thead>
</table>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="bi bi-tools mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucune intervention enregistrée sur cette localisation ou ses aménagements." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 5: Documents -->
<div class="tab-pane fade" id="documents-pane" role="tabpanel" aria-labelledby="documents-tab">
{% if documents %}
<div class="row g-3">
{% for document in documents %}
<div class="col-6 col-sm-4 col-md-3">
<div class="card h-100 shadow-sm border p-2 text-center">
<a href="{{ document.url }}" target="_blank" class="text-decoration-none text-dark">
<div class="img-thumbnail d-flex align-items-center justify-content-center mx-auto mb-2" style="width: 100%; height: 90px; overflow: hidden; background: #f8fafc;">
{% if document.thumbnail_url %}
<img src="{{ document.thumbnail_url }}" alt="{{ document.title }}" style="max-height: 100%; max-width: 100%; object-fit: contain;">
{% else %}
<i class="bi bi-file-earmark-text fs-2 text-secondary"></i>
{% endif %}
</div>
<div class="fw-semibold small text-truncate" title="{{ document.title }}">{{ document.title }}</div>
{% if document.tag %}
<span class="badge bg-primary-subtle text-primary border my-1" style="font-size: 0.7rem;">{{ document.tag }}</span>
{% endif %}
{% if document.date %}
<div class="text-muted" style="font-size: 0.7rem;">{{ document.date|date:"d/m/Y" }}</div>
{% endif %}
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-folder2-open mb-2 d-block fs-2 text-muted"></i>
{% translate "Aucun document associé à cette localisation." %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Right Column: Interactive Map -->
<div class="col-lg-5 col-xl-5">
<div class="card shadow-sm border-0 map-card-wrapper">
<div class="card-header bg-white py-2 px-3 d-flex align-items-center justify-content-between flex-wrap gap-2 border-bottom">
<span class="fw-bold text-dark d-flex align-items-center">
<i class="bi bi-geo-alt-fill text-success me-2"></i>{% translate "Carte & Aménagements" %}
</span>
<!-- Map Layer Controls / Legend -->
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-surfaces" checked onchange="toggleNatureLayer('surfaces', this.checked)">
<label class="form-check-label" for="toggle-surfaces"><span class="badge bg-success" style="width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Surfaces" %}</label>
</div>
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-trees" checked onchange="toggleNatureLayer('trees', this.checked)">
<label class="form-check-label" for="toggle-trees"><span class="badge bg-success" style="border-radius: 50%; width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Arbres" %}</label>
</div>
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-equipments" checked onchange="toggleNatureLayer('shrubs', this.checked); toggleNatureLayer('benches', this.checked)">
<label class="form-check-label" for="toggle-equipments"><span class="badge bg-warning" style="border-radius: 50%; width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Autres" %}</label>
</div>
</div>
</div>
<div class="card-body p-0 position-relative">
<div id="map"></div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div id="map">
</div>
</div>
</div>
{% 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" }}
<script>
const feature_geojson = JSON.parse(document.getElementById("geojson_value").textContent);
const feature_lon = JSON.parse(document.getElementById("lon_value").textContent);
const feature_lat = JSON.parse(document.getElementById("lat_value").textContent);
const section_geojson = JSON.parse(document.getElementById("section_geojson_value").textContent);
const assets_geojson = JSON.parse(document.getElementById("assets_geojson_value").textContent);
</script>
<script src="{% static 'assets/green_detail.js' %}"></script>
<script src="{% static 'common/turf.min.js' %}"></script>
<script src="{% static 'assets/nature_location_detail.js' %}"></script>
{% endblock script %}

View file

@ -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é")

View file

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