feat: implement nature location detail view with asset summaries, KPIs, and unit tests
This commit is contained in:
parent
f324eb00bd
commit
11e1674f73
4 changed files with 1431 additions and 88 deletions
309
loko/assets/static/assets/nature_location_detail.js
Normal file
309
loko/assets/static/assets/nature_location_detail.js
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,116 +2,704 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-2" style="height: calc(100vh - 200px);">
|
<div class="container-fluid py-3">
|
||||||
<div class="col-md-8" style="max-height: calc(100vh - 200px); overflow: auto;">
|
|
||||||
<h3 class="h3">
|
<!-- Breadcrumb & Navigation -->
|
||||||
<i class="bi bi-suit-club"></i><span class="mx-2">{{ section.code}} - {{ section.name_fr}}</span>
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
</h3>
|
<nav aria-label="breadcrumb">
|
||||||
<hr>
|
<ol class="breadcrumb mb-0">
|
||||||
<div class="row mb-4">
|
<li class="breadcrumb-item"><a href="{% url 'assets:nature_index' %}" class="text-decoration-none">{% translate "Nature" %}</a></li>
|
||||||
<div class="col-md-6">
|
<li class="breadcrumb-item"><a href="{% url 'assets:nature_locations' %}" class="text-decoration-none">{% translate "Voiries" %}</a></li>
|
||||||
<label class="mx-2">{% translate "Zone:" %}</label><i class="bi bi-circle-fill status_{{ surface.status }}"></i> {{ section.zone|default:"" }}<br>
|
<li class="breadcrumb-item active fw-semibold" aria-current="page">{{ section.code }}</li>
|
||||||
<label class="mx-2">{% translate "Autoroute:" %}</label>{{ section.is_highway|default:"" }}<br>
|
</ol>
|
||||||
</div>
|
</nav>
|
||||||
<div class="col-md-6">
|
<div class="d-flex gap-2">
|
||||||
{% if section.lon %}
|
<a href="{% url 'assets:nature_index' %}" class="btn btn-sm btn-outline-secondary">
|
||||||
<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;">
|
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la carte" %}
|
||||||
<img src="{% static 'common/google-street-view-logo.png' %}" alt="{% translate 'Street View' %}" height="20" style="margin-right: 8px;">
|
</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" %}
|
{% translate "View in Google Street View" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-12">
|
<!-- Header Card -->
|
||||||
<label class="mx-2">{% translate "Nombre d'arbres:" %}</label><br>
|
<div class="card shadow-sm mb-4 border-0" style="background: linear-gradient(to right, #f8fafc, #ffffff); border-left: 5px solid #16a34a !important;">
|
||||||
<label class="mx-2">{% translate "Superficie massif arboré:" %}</label><br>
|
<div class="card-body p-3 p-md-4">
|
||||||
<label class="mx-2">{% translate "Superficie pré fleuri:" %}</label><br>
|
<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">— {{ 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>•</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>•</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>•</span>
|
||||||
|
<span><i class="bi bi-tag me-1"></i>{{ section.category.get_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Documents -->
|
{% if section.parent_location or section.child_locations.exists %}
|
||||||
<div class="card mb-4">
|
<div class="mt-2 pt-2 border-top d-flex gap-3 flex-wrap small text-muted">
|
||||||
<div class="card-header">
|
{% if section.parent_location %}
|
||||||
<h4>{% translate "Documents" %}</h4>
|
<div>
|
||||||
</div>
|
<i class="bi bi-diagram-2 me-1"></i>{% translate "Section parente :" %}
|
||||||
<div class="card-body">
|
<a href="{{ section.parent_location.get_absolute_url }}" class="fw-semibold text-decoration-none">
|
||||||
<div class="row">
|
{{ section.parent_location.code }} - {{ section.parent_location.get_name }}
|
||||||
{% 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;">
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 %}
|
||||||
|
• <span class="text-warning fw-semibold"><i class="bi bi-star-fill text-warning"></i> {{ trees_remarkable_count }} {% translate "remarquables" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Interventions -->
|
<!-- Main Content Layout (Split View: Tabs/Tables on Left, Interactive Map on Right) -->
|
||||||
<div class="card mb-4">
|
<div class="row g-4">
|
||||||
<div class="card-header">
|
<!-- Left Column: Tabs & Tables -->
|
||||||
<h4>{% translate "Interventions" %}</h4>
|
<div class="col-lg-7 col-xl-7">
|
||||||
</div>
|
<!-- Tabs Navigation -->
|
||||||
<div class="card-body">
|
<ul class="nav nav-tabs" id="natureLocationTabs" role="tablist">
|
||||||
<table id="interventions-table" class="table"
|
<li class="nav-item" role="presentation">
|
||||||
data-height="550"
|
<button class="nav-link active fw-semibold" id="surfaces-tab" data-bs-toggle="tab" data-bs-target="#surfaces-pane" type="button" role="tab">
|
||||||
data-pagination="true"
|
<i class="bi bi-grid-fill text-success me-1"></i>{% translate "Surfaces vertes" %}
|
||||||
data-page-size="25"
|
<span class="badge bg-success-subtle text-success ms-1">{{ green_surfaces_count }}</span>
|
||||||
data-click-to-select="true" >
|
</button>
|
||||||
<thead>
|
</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>
|
<tr>
|
||||||
<th data-field="actions" data-width="100"></th>
|
<th>{% translate "Code" %}</th>
|
||||||
<th data-field="code">{% translate "Code" %}</th>
|
<th>{% translate "Typologie / Modèle" %}</th>
|
||||||
<th data-field="title">{% translate "Titre" %}</th>
|
<th class="text-end">{% translate "Superficie" %}</th>
|
||||||
<th data-field="begin_date">{% translate "Début" %}</th>
|
<th>{% translate "Pente / Tonte" %}</th>
|
||||||
<th data-field="statut">{% translate "Statut" %}</th>
|
<th>{% translate "Bulbes" %}</th>
|
||||||
|
<th>{% translate "Statut" %}</th>
|
||||||
|
<th class="text-end">{% translate "Actions" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for interv in interventions %}
|
{% for gs in green_surfaces %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="fw-bold">
|
||||||
<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' %}">
|
<a href="{% url 'assets:nature_assets_detail' 'naturegreensurface' gs.id %}" class="text-decoration-none">
|
||||||
<i class="bi bi-info-circle"></i>
|
{{ gs.code }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ interv.code }}</td>
|
<td>
|
||||||
<td>{{ interv.title }}</td>
|
<span class="d-inline-block rounded-circle me-1" style="width: 8px; height: 8px; background-color: {{ gs.model.color|default:'#4caf50' }};"></span>
|
||||||
<td>{{ interv.begin_time|date:"d-m-Y H:i" }}</td>
|
{{ gs.model.get_name|default:"-" }}
|
||||||
<td>{{ interv.get_status_display }}</td>
|
</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</thead>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<!-- TAB 3: Arbustes & Bancs -->
|
||||||
<div id="map">
|
<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 class="fw-bold">{{ interv.code }}</td>
|
||||||
|
<td>{{ interv.title }}</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>
|
||||||
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
{{ section.lon|json_script:"lon_value" }}
|
{{ feature_lon|json_script:"lon_value" }}
|
||||||
{{ section.lat|json_script:"lat_value" }}
|
{{ feature_lat|json_script:"lat_value" }}
|
||||||
{{ section.geojson|json_script:"geojson_value" }}
|
{{ section_geojson|json_script:"section_geojson_value" }}
|
||||||
|
{{ assets_geojson|json_script:"assets_geojson_value" }}
|
||||||
<script>
|
<script>
|
||||||
const feature_geojson = JSON.parse(document.getElementById("geojson_value").textContent);
|
|
||||||
const feature_lon = JSON.parse(document.getElementById("lon_value").textContent);
|
const feature_lon = JSON.parse(document.getElementById("lon_value").textContent);
|
||||||
const feature_lat = JSON.parse(document.getElementById("lat_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>
|
||||||
<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 %}
|
{% endblock script %}
|
||||||
|
|
|
||||||
|
|
@ -5440,3 +5440,156 @@ class UserAccessibleMunicipalitiesTest(TestCase):
|
||||||
muni_filter = next((f for f in roads_filters if f['key'] == 'municipality'), None)
|
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
|
# When only 1 municipality is accessible, the filter should NOT be shown
|
||||||
self.assertIsNone(muni_filter)
|
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é")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.gis.db.models.functions import Distance
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import get_language, gettext as _
|
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 import Max
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.views.decorators.http import require_GET
|
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 NatureLocation, NatureGreenSurface, NatureAssetModel, NatureTree, NatureShrub, NatureBench, AssetCategory, NatureRWIZ, NatureRWIAsset
|
||||||
from assets.models import GreenSurfacesComment, GreenSurfacesCommentPhoto
|
from assets.models import GreenSurfacesComment, GreenSurfacesCommentPhoto
|
||||||
|
|
||||||
from interventions.models import InterventionLocation
|
from interventions.models import Intervention, InterventionLocation, InterventionAsset
|
||||||
from interventions.views.drafts import InterventionDraftManager
|
from interventions.views.drafts import InterventionDraftManager
|
||||||
|
|
||||||
@check_thematic_access('nature')
|
@check_thematic_access('nature')
|
||||||
|
|
@ -118,11 +118,304 @@ def nature_locations_detail(request, location_id):
|
||||||
section = get_object_or_404(NatureLocation, id=location_id)
|
section = get_object_or_404(NatureLocation, id=location_id)
|
||||||
content_type = ContentType.objects.get_for_model(section)
|
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 = {
|
context = {
|
||||||
'section': section,
|
'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,
|
'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)
|
return render(request, "assets/nature_locations_detail.html", context)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue