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 () => {
// 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

View file

@ -186,5 +186,5 @@
</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 %}

View file

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