perf: optimize activity_geojson query, add caching and parallelize map data fetching

This commit is contained in:
kdeterme 2026-09-21 16:14:56 +02:00
parent f0fb0df8ec
commit de1d1c3b43
3 changed files with 152 additions and 97 deletions

View file

@ -121,16 +121,20 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
} }
map.on('load', async () => { map.on('load', async () => {
// 0. Enregistrer les icônes d'opérations (symboles à l'intérieur des points) // Enregistrer les icônes d'opérations et lancer les requêtes GeoJSON en parallèle (gain réseau immédiat)
await Promise.all( const [maskData, actData] = await Promise.all([
Object.entries(OPERATION_ICONS).map(([key, info]) => registerOperationIcon(map, key, info, 32)) 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 // 1. Appliquer le masque de mise en lumière du territoire communal
if (maskData) {
try { try {
const maskResp = await fetch('/common/territory-mask-geojson/');
if (maskResp.ok) {
const maskData = await maskResp.json();
map.addSource('territory-mask', { map.addSource('territory-mask', {
type: 'geojson', type: 'geojson',
data: maskData data: maskData
@ -207,27 +211,19 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
duration: 1000 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) { } else if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
map.fitBounds(window.MAP_DEFAULT_EXTENT, { map.fitBounds(window.MAP_DEFAULT_EXTENT, {
padding: { top: 35, bottom: 35, left: 35, right: 35 }, padding: { top: 35, bottom: 35, left: 35, right: 35 },
duration: 1000 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
});
}
}
// 2. Charger les données d'activité isolées par contrat // 2. Appliquer les données d'activité isolées par contrat
if (actData) {
try { try {
const actResp = await fetch('/api/activity-geojson/');
if (actResp.ok) {
const actData = await actResp.json();
map.addSource('activity-data', { map.addSource('activity-data', {
type: 'geojson', type: 'geojson',
data: actData data: actData

View file

@ -186,5 +186,5 @@
</script> </script>
<script src="{% static 'interventions/status_colors.js' %}"></script> <script src="{% static 'interventions/status_colors.js' %}"></script>
<script src="{% static 'home/home_index.js' %}"></script> <script src="{% static 'home/home_index.js' %}?v=20260921_1"></script>
{% endblock script %} {% endblock script %}

View file

@ -1218,8 +1218,11 @@ def activity_geojson(request):
""" """
Renvoie le GeoJSON des actions des 90 derniers jours (interventions, observations, inspections, contrôles) Renvoie le GeoJSON des actions des 90 derniers jours (interventions, observations, inspections, contrôles)
strictement restreintes aux contrats autorisés pour l'utilisateur connecté. 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 contracts.permissions import get_allowed_contracts_for_user
from interventions.models import Intervention from interventions.models import Intervention
from observations.models import Observation from observations.models import Observation
@ -1230,24 +1233,52 @@ def activity_geojson(request):
if not allowed_contracts.exists(): if not allowed_contracts.exists():
return JsonResponse({"type": "FeatureCollection", "features": []}) 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) cutoff = timezone.now() - timedelta(days=90)
features = [] features = []
# 1. Interventions du contrat # URLs de base précalculées pour éviter d'appeler reverse() des milliers de fois
intervs = Intervention.objects.filter( 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, contract__in=allowed_contracts,
creation_time__gte=cutoff creation_time__gte=cutoff
).exclude(geom__isnull=True).select_related('thematic') )
.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: for item in intervs:
geom_wgs = item.geom.centroid.clone() pt = getattr(item, 'geom_4326', None)
geom_wgs.transform(4326) if not pt:
continue
t_key = _normalize_thematic_key(item.thematic, f"{item.title} {item.description or ''}") t_key = _normalize_thematic_key(item.thematic, f"{item.title} {item.description or ''}")
features.append({ features.append({
"type": "Feature", "type": "Feature",
"geometry": { "geometry": {
"type": "Point", "type": "Point",
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)] "coordinates": [round(pt.x, 6), round(pt.y, 6)]
}, },
"properties": { "properties": {
"id": f"int-{item.id}", "id": f"int-{item.id}",
@ -1259,28 +1290,38 @@ def activity_geojson(request):
"code": item.code or f"INT-{item.id}", "code": item.code or f"INT-{item.id}",
"title": item.title or "-", "title": item.title or "-",
"status": item.status, "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 "", "address": item.address or "",
"date": item.creation_time.strftime("%d/%m/%Y") if item.creation_time else "", "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) # 2. Observations liées à un contrat autorisé (projection EPSG:4326 PostGIS)
obs = Observation.objects.filter( obs = (
Observation.objects.filter(
intervention__contract__in=allowed_contracts, intervention__contract__in=allowed_contracts,
created_at__gte=cutoff created_at__gte=cutoff
).exclude(geom__isnull=True).select_related('thematic') )
.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: for item in obs:
geom_wgs = item.geom.clone() pt = getattr(item, 'geom_4326', None)
geom_wgs.transform(4326) if not pt:
continue
t_key = _normalize_thematic_key(item.thematic, f"{item.description or ''}") t_key = _normalize_thematic_key(item.thematic, f"{item.description or ''}")
features.append({ features.append({
"type": "Feature", "type": "Feature",
"geometry": { "geometry": {
"type": "Point", "type": "Point",
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)] "coordinates": [round(pt.x, 6), round(pt.y, 6)]
}, },
"properties": { "properties": {
"id": f"obs-{item.id}", "id": f"obs-{item.id}",
@ -1292,30 +1333,40 @@ def activity_geojson(request):
"code": item.code or f"OBS-{item.id}", "code": item.code or f"OBS-{item.id}",
"title": item.description[:60] if item.description else "-", "title": item.description[:60] if item.description else "-",
"status": item.status, "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 "", "address": item.address or "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "", "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é # 3. Inspections liées à un contrat autorisé (projection EPSG:4326 PostGIS)
insps = Inspection.objects.filter( insps = (
Inspection.objects.filter(
mission_intervention__contract__in=allowed_contracts, mission_intervention__contract__in=allowed_contracts,
created_at__gte=cutoff created_at__gte=cutoff
).select_related('mission_intervention', 'mission_intervention__thematic') )
.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: for item in insps:
if item.mission_intervention and item.mission_intervention.geom: pt = getattr(item, 'geom_4326', None)
geom_wgs = item.mission_intervention.geom.centroid.clone() if not pt:
geom_wgs.transform(4326) continue
thematic_obj = item.mission_intervention.thematic if (item.mission_intervention and item.mission_intervention.thematic) else None 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 ''}") t_key = _normalize_thematic_key(thematic_obj, f"{item.nature or ''} {item.notes or ''}")
features.append({ features.append({
"type": "Feature", "type": "Feature",
"geometry": { "geometry": {
"type": "Point", "type": "Point",
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)] "coordinates": [round(pt.x, 6), round(pt.y, 6)]
}, },
"properties": { "properties": {
"id": f"insp-{item.id}", "id": f"insp-{item.id}",
@ -1327,32 +1378,38 @@ def activity_geojson(request):
"code": f"INSP-{item.id:04d}", "code": f"INSP-{item.id:04d}",
"title": item.nature or "Inspection", "title": item.nature or "Inspection",
"status": item.status, "status": item.status,
"status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status, "status_label": insp_status_dict.get(item.status, item.status),
"address": item.mission_intervention.address or "", "address": item.mission_intervention.address if item.mission_intervention else "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "", "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}", "url": f"{insp_list_url}?tab=all&inspection_id={item.id}#inspectionDetailModal{item.id}",
} }
}) })
# 4. Contrôles liés à un contrat autorisé # 4. Contrôles liés à un contrat autorisé (avec prefetch_related pour supprimer les requêtes N+1)
ctrls = Control.objects.filter( ctrls = (
Control.objects.filter(
interventions__contract__in=allowed_contracts, interventions__contract__in=allowed_contracts,
created_at__gte=cutoff created_at__gte=cutoff
).distinct().exclude(geom__isnull=True) )
.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: for item in ctrls:
geom_wgs = item.geom.clone() pt = getattr(item, 'geom_4326', None)
geom_wgs.transform(4326) if not pt:
thematic_obj = None continue
first_interv = item.interventions.select_related('thematic').first() first_interv = next(iter(item.interventions.all()), None)
if first_interv and first_interv.thematic: thematic_obj = first_interv.thematic if (first_interv and first_interv.thematic) else None
thematic_obj = first_interv.thematic
t_key = _normalize_thematic_key(thematic_obj, f"{item.description or ''} {item.control_code or ''}") t_key = _normalize_thematic_key(thematic_obj, f"{item.description or ''} {item.control_code or ''}")
features.append({ features.append({
"type": "Feature", "type": "Feature",
"geometry": { "geometry": {
"type": "Point", "type": "Point",
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)] "coordinates": [round(pt.x, 6), round(pt.y, 6)]
}, },
"properties": { "properties": {
"id": f"ctrl-{item.id}", "id": f"ctrl-{item.id}",
@ -1364,14 +1421,16 @@ def activity_geojson(request):
"code": item.control_code or f"CTRL-{item.id}", "code": item.control_code or f"CTRL-{item.id}",
"title": item.description[:60] if item.description else "-", "title": item.description[:60] if item.description else "-",
"status": item.status, "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 "", "address": item.location or "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "", "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", "type": "FeatureCollection",
"features": features "features": features
}) }
cache.set(cache_key, result_data, timeout=120)
return JsonResponse(result_data)