From de1d1c3b435488792d0141415bac86f34d1d8f92 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 21 Sep 2026 16:14:56 +0200 Subject: [PATCH] perf: optimize activity_geojson query, add caching and parallelize map data fetching --- loko/home/static/home/home_index.js | 50 +++--- loko/home/templates/home/home_index.html | 2 +- loko/home/views.py | 197 +++++++++++++++-------- 3 files changed, 152 insertions(+), 97 deletions(-) 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)