diff --git a/loko/home/static/home/home_index.js b/loko/home/static/home/home_index.js
index c6fca9d..9bfc4f9 100644
--- a/loko/home/static/home/home_index.js
+++ b/loko/home/static/home/home_index.js
@@ -121,16 +121,20 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
}
map.on('load', async () => {
- // 0. Enregistrer les icônes d'opérations (symboles à l'intérieur des points)
- await Promise.all(
- Object.entries(OPERATION_ICONS).map(([key, info]) => registerOperationIcon(map, key, info, 32))
- );
+ // Enregistrer les icônes d'opérations et lancer les requêtes GeoJSON en parallèle (gain réseau immédiat)
+ const [maskData, actData] = await Promise.all([
+ fetch('/common/territory-mask-geojson/')
+ .then(r => r.ok ? r.json() : null)
+ .catch(e => { console.warn("Could not load territory mask:", e); return null; }),
+ fetch('/api/activity-geojson/')
+ .then(r => r.ok ? r.json() : null)
+ .catch(e => { console.error("Could not load activity geojson:", e); return null; }),
+ ...Object.entries(OPERATION_ICONS).map(([key, info]) => registerOperationIcon(map, key, info, 32))
+ ]);
- // 1. Charger le masque de mise en lumière du territoire communal
- try {
- const maskResp = await fetch('/common/territory-mask-geojson/');
- if (maskResp.ok) {
- const maskData = await maskResp.json();
+ // 1. Appliquer le masque de mise en lumière du territoire communal
+ if (maskData) {
+ try {
map.addSource('territory-mask', {
type: 'geojson',
data: maskData
@@ -207,27 +211,19 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
duration: 1000
});
}
- } else if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
- map.fitBounds(window.MAP_DEFAULT_EXTENT, {
- padding: { top: 35, bottom: 35, left: 35, right: 35 },
- duration: 1000
- });
- }
- } catch (e) {
- console.warn("Could not load territory mask:", e);
- if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
- map.fitBounds(window.MAP_DEFAULT_EXTENT, {
- padding: { top: 35, bottom: 35, left: 35, right: 35 },
- duration: 1000
- });
+ } catch (e) {
+ console.warn("Error rendering territory mask:", e);
}
+ } else if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
+ map.fitBounds(window.MAP_DEFAULT_EXTENT, {
+ padding: { top: 35, bottom: 35, left: 35, right: 35 },
+ duration: 1000
+ });
}
- // 2. Charger les données d'activité isolées par contrat
- try {
- const actResp = await fetch('/api/activity-geojson/');
- if (actResp.ok) {
- const actData = await actResp.json();
+ // 2. Appliquer les données d'activité isolées par contrat
+ if (actData) {
+ try {
map.addSource('activity-data', {
type: 'geojson',
data: actData
diff --git a/loko/home/templates/home/home_index.html b/loko/home/templates/home/home_index.html
index c4d6bac..33968aa 100644
--- a/loko/home/templates/home/home_index.html
+++ b/loko/home/templates/home/home_index.html
@@ -186,5 +186,5 @@
-
+
{% endblock script %}
diff --git a/loko/home/views.py b/loko/home/views.py
index bf6385f..bb46afc 100644
--- a/loko/home/views.py
+++ b/loko/home/views.py
@@ -1218,8 +1218,11 @@ def activity_geojson(request):
"""
Renvoie le GeoJSON des actions des 90 derniers jours (interventions, observations, inspections, contrôles)
strictement restreintes aux contrats autorisés pour l'utilisateur connecté.
- Permet d'isoler parfaitement les démonstrations de chaque commune.
+ Optimisé avec projection PostGIS directe, élimination des N+1 requêtes et mise en cache.
"""
+ import hashlib
+ from django.core.cache import cache
+ from django.contrib.gis.db.models.functions import Centroid, Transform
from contracts.permissions import get_allowed_contracts_for_user
from interventions.models import Intervention
from observations.models import Observation
@@ -1230,24 +1233,52 @@ def activity_geojson(request):
if not allowed_contracts.exists():
return JsonResponse({"type": "FeatureCollection", "features": []})
+ contract_ids = tuple(sorted(allowed_contracts.values_list('id', flat=True)))
+ cache_key = f"activity_geojson_{hashlib.md5(str(contract_ids).encode()).hexdigest()}"
+ cached_data = cache.get(cache_key)
+ if cached_data is not None:
+ return JsonResponse(cached_data)
+
cutoff = timezone.now() - timedelta(days=90)
features = []
- # 1. Interventions du contrat
- intervs = Intervention.objects.filter(
- contract__in=allowed_contracts,
- creation_time__gte=cutoff
- ).exclude(geom__isnull=True).select_related('thematic')
+ # URLs de base précalculées pour éviter d'appeler reverse() des milliers de fois
+ interv_base_url = reverse("interventions:intervention_detail", args=[0])[:-2]
+ obs_base_url = reverse("observations:observation_detail", args=[0])[:-2]
+ ctrl_base_url = reverse("controls:controls_detail", args=[0])[:-2]
+ insp_list_url = reverse("inspections:inspections_list")
+
+ # Dictionnaires de libellés de statut pour accès O(1)
+ interv_status_dict = dict(Intervention.STATUS_CHOICES)
+ obs_status_dict = dict(Observation.STATUS_CHOICES)
+ insp_status_dict = dict(getattr(Inspection, 'STATUS_CHOICES', []))
+ ctrl_status_dict = dict(getattr(Control, 'STATUS_CHOICES', []))
+
+ # 1. Interventions du contrat (projection EPSG:4326 au niveau PostGIS)
+ intervs = (
+ Intervention.objects.filter(
+ contract__in=allowed_contracts,
+ creation_time__gte=cutoff
+ )
+ .exclude(geom__isnull=True)
+ .select_related('thematic')
+ .annotate(geom_4326=Transform(Centroid('geom'), 4326))
+ .only(
+ 'id', 'code', 'title', 'description', 'status', 'address', 'creation_time',
+ 'thematic__id', 'thematic__name', 'thematic__code'
+ )
+ )
for item in intervs:
- geom_wgs = item.geom.centroid.clone()
- geom_wgs.transform(4326)
+ pt = getattr(item, 'geom_4326', None)
+ if not pt:
+ continue
t_key = _normalize_thematic_key(item.thematic, f"{item.title} {item.description or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
- "coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
+ "coordinates": [round(pt.x, 6), round(pt.y, 6)]
},
"properties": {
"id": f"int-{item.id}",
@@ -1259,28 +1290,38 @@ def activity_geojson(request):
"code": item.code or f"INT-{item.id}",
"title": item.title or "-",
"status": item.status,
- "status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
+ "status_label": interv_status_dict.get(item.status, item.status),
"address": item.address or "",
"date": item.creation_time.strftime("%d/%m/%Y") if item.creation_time else "",
- "url": reverse("interventions:intervention_detail", args=[item.id]),
+ "url": f"{interv_base_url}/{item.id}/",
}
})
- # 2. Observations liées à un contrat autorisé (via leur intervention)
- obs = Observation.objects.filter(
- intervention__contract__in=allowed_contracts,
- created_at__gte=cutoff
- ).exclude(geom__isnull=True).select_related('thematic')
+ # 2. Observations liées à un contrat autorisé (projection EPSG:4326 PostGIS)
+ obs = (
+ Observation.objects.filter(
+ intervention__contract__in=allowed_contracts,
+ created_at__gte=cutoff
+ )
+ .exclude(geom__isnull=True)
+ .select_related('thematic')
+ .annotate(geom_4326=Transform(Centroid('geom'), 4326))
+ .only(
+ 'id', 'code', 'description', 'status', 'address', 'created_at',
+ 'thematic__id', 'thematic__name', 'thematic__code'
+ )
+ )
for item in obs:
- geom_wgs = item.geom.clone()
- geom_wgs.transform(4326)
+ pt = getattr(item, 'geom_4326', None)
+ if not pt:
+ continue
t_key = _normalize_thematic_key(item.thematic, f"{item.description or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
- "coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
+ "coordinates": [round(pt.x, 6), round(pt.y, 6)]
},
"properties": {
"id": f"obs-{item.id}",
@@ -1292,67 +1333,83 @@ def activity_geojson(request):
"code": item.code or f"OBS-{item.id}",
"title": item.description[:60] if item.description else "-",
"status": item.status,
- "status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
+ "status_label": obs_status_dict.get(item.status, item.status),
"address": item.address or "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
- "url": reverse("observations:observation_detail", args=[item.id]),
+ "url": f"{obs_base_url}/{item.id}/",
}
})
- # 3. Inspections liées à un contrat autorisé
- insps = Inspection.objects.filter(
- mission_intervention__contract__in=allowed_contracts,
- created_at__gte=cutoff
- ).select_related('mission_intervention', 'mission_intervention__thematic')
+ # 3. Inspections liées à un contrat autorisé (projection EPSG:4326 PostGIS)
+ insps = (
+ Inspection.objects.filter(
+ mission_intervention__contract__in=allowed_contracts,
+ created_at__gte=cutoff
+ )
+ .exclude(mission_intervention__geom__isnull=True)
+ .select_related('mission_intervention', 'mission_intervention__thematic')
+ .annotate(geom_4326=Transform(Centroid('mission_intervention__geom'), 4326))
+ .only(
+ 'id', 'nature', 'notes', 'status', 'created_at',
+ 'mission_intervention__id', 'mission_intervention__address',
+ 'mission_intervention__thematic__id', 'mission_intervention__thematic__name', 'mission_intervention__thematic__code'
+ )
+ )
for item in insps:
- if item.mission_intervention and item.mission_intervention.geom:
- geom_wgs = item.mission_intervention.geom.centroid.clone()
- geom_wgs.transform(4326)
- thematic_obj = item.mission_intervention.thematic if (item.mission_intervention and item.mission_intervention.thematic) else None
- t_key = _normalize_thematic_key(thematic_obj, f"{item.nature or ''} {item.notes or ''}")
- features.append({
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
- },
- "properties": {
- "id": f"insp-{item.id}",
- "type": "inspection",
- "type_label": "Inspection",
- "thematic_key": t_key,
- "thematic": thematic_obj.name if thematic_obj else "Patrimoine",
- "icon": "bi-search",
- "code": f"INSP-{item.id:04d}",
- "title": item.nature or "Inspection",
- "status": item.status,
- "status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
- "address": item.mission_intervention.address or "",
- "date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
- "url": reverse("inspections:inspections_list") + f"?tab=all&inspection_id={item.id}#inspectionDetailModal{item.id}",
- }
- })
+ pt = getattr(item, 'geom_4326', None)
+ if not pt:
+ continue
+ thematic_obj = item.mission_intervention.thematic if (item.mission_intervention and item.mission_intervention.thematic) else None
+ t_key = _normalize_thematic_key(thematic_obj, f"{item.nature or ''} {item.notes or ''}")
+ features.append({
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [round(pt.x, 6), round(pt.y, 6)]
+ },
+ "properties": {
+ "id": f"insp-{item.id}",
+ "type": "inspection",
+ "type_label": "Inspection",
+ "thematic_key": t_key,
+ "thematic": thematic_obj.name if thematic_obj else "Patrimoine",
+ "icon": "bi-search",
+ "code": f"INSP-{item.id:04d}",
+ "title": item.nature or "Inspection",
+ "status": item.status,
+ "status_label": insp_status_dict.get(item.status, item.status),
+ "address": item.mission_intervention.address if item.mission_intervention else "",
+ "date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
+ "url": f"{insp_list_url}?tab=all&inspection_id={item.id}#inspectionDetailModal{item.id}",
+ }
+ })
- # 4. Contrôles liés à un contrat autorisé
- ctrls = Control.objects.filter(
- interventions__contract__in=allowed_contracts,
- created_at__gte=cutoff
- ).distinct().exclude(geom__isnull=True)
+ # 4. Contrôles liés à un contrat autorisé (avec prefetch_related pour supprimer les requêtes N+1)
+ ctrls = (
+ Control.objects.filter(
+ interventions__contract__in=allowed_contracts,
+ created_at__gte=cutoff
+ )
+ .distinct()
+ .exclude(geom__isnull=True)
+ .prefetch_related('interventions__thematic')
+ .annotate(geom_4326=Transform(Centroid('geom'), 4326))
+ .only('id', 'control_code', 'description', 'status', 'location', 'created_at')
+ )
for item in ctrls:
- geom_wgs = item.geom.clone()
- geom_wgs.transform(4326)
- thematic_obj = None
- first_interv = item.interventions.select_related('thematic').first()
- if first_interv and first_interv.thematic:
- thematic_obj = first_interv.thematic
+ pt = getattr(item, 'geom_4326', None)
+ if not pt:
+ continue
+ first_interv = next(iter(item.interventions.all()), None)
+ thematic_obj = first_interv.thematic if (first_interv and first_interv.thematic) else None
t_key = _normalize_thematic_key(thematic_obj, f"{item.description or ''} {item.control_code or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
- "coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
+ "coordinates": [round(pt.x, 6), round(pt.y, 6)]
},
"properties": {
"id": f"ctrl-{item.id}",
@@ -1364,14 +1421,16 @@ def activity_geojson(request):
"code": item.control_code or f"CTRL-{item.id}",
"title": item.description[:60] if item.description else "-",
"status": item.status,
- "status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
+ "status_label": ctrl_status_dict.get(item.status, item.status),
"address": item.location or "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
- "url": reverse("controls:controls_detail", args=[item.id]),
+ "url": f"{ctrl_base_url}/{item.id}/",
}
})
- return JsonResponse({
+ result_data = {
"type": "FeatureCollection",
"features": features
- })
+ }
+ cache.set(cache_key, result_data, timeout=120)
+ return JsonResponse(result_data)