feat: optimize context processing

This commit is contained in:
kdeterme 2026-09-19 18:33:17 +02:00
parent d73a9e482d
commit e68e6ccefe
3 changed files with 138 additions and 9 deletions

View file

@ -4,6 +4,7 @@ import os
from typing import Iterable, List
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
from django.urls import resolve
from .models import UserConfig, AppView
@ -11,6 +12,7 @@ from .models import UserConfig, AppView
SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids"
SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id"
SESSION_MAP_BOUNDS_KEY = "user_map_bounds"
from django.conf import settings
@ -55,6 +57,7 @@ def current_app_view(request):
Only 'sign' and 'documents' have their own dedicated views.
All other apps (assets, interventions, controls, contracts, etc.) use 'asset_management' view.
Results are cached to avoid hitting the database on every page render.
"""
current_view = None
@ -67,13 +70,20 @@ def current_app_view(request):
namespace = resolved.namespace # e.g., 'sign', 'home', 'assets'
if namespace:
if namespace in DEDICATED_VIEW_APPS:
# These apps have their own dedicated view
current_view = AppView.objects.filter(url_name__startswith=f"{namespace}:").first()
cache_key = f"appview_for_{namespace}"
cached_view = cache.get(cache_key)
if cached_view is not None:
current_view = None if cached_view is False else cached_view
else:
# All other apps (home, assets, interventions, controls, contracts, etc.)
# should show 'asset_management' view
current_view = AppView.objects.filter(code='asset_management').first()
if namespace in DEDICATED_VIEW_APPS:
# These apps have their own dedicated view
current_view = AppView.objects.filter(url_name__startswith=f"{namespace}:").first()
else:
# All other apps (home, assets, interventions, controls, contracts, etc.)
# should show 'asset_management' view
current_view = AppView.objects.filter(code='asset_management').first()
cache.set(cache_key, current_view if current_view is not None else False, 3600)
except Exception:
pass
@ -88,6 +98,7 @@ def linked_accounts(request):
if not user or isinstance(user, AnonymousUser) or not user.is_authenticated:
request.session.pop(SESSION_ALLOWED_IDS_KEY, None)
request.session.pop(SESSION_ORIGIN_ID_KEY, None)
request.session.pop(SESSION_MAP_BOUNDS_KEY, None)
return {
"switchable_accounts": [],
"account_switch_origin_user_id": None,
@ -98,6 +109,7 @@ def linked_accounts(request):
except UserConfig.DoesNotExist:
request.session.pop(SESSION_ALLOWED_IDS_KEY, None)
request.session.pop(SESSION_ORIGIN_ID_KEY, None)
request.session.pop(SESSION_MAP_BOUNDS_KEY, None)
return {
"switchable_accounts": [],
"account_switch_origin_user_id": None,
@ -140,15 +152,83 @@ def linked_accounts(request):
def organization_info(request):
"""
Context processor that exposes organization details from settings.
Dynamically adapts map_default_center, map_default_zoom and map_default_extent
when the authenticated user has an assigned contract associated with a municipality.
Caches calculated bounds in session to avoid heavy GIS and SQL processing on every page.
"""
center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)]
zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11)
extent = getattr(settings, 'MAP_DEFAULT_EXTENT', None)
if hasattr(request, 'user') and request.user.is_authenticated:
session = getattr(request, 'session', None)
cached_bounds = session.get(SESSION_MAP_BOUNDS_KEY) if session is not None else None
if cached_bounds and cached_bounds.get('user_id') == request.user.pk:
center = cached_bounds.get('center', center)
zoom = cached_bounds.get('zoom', zoom)
extent = cached_bounds.get('extent', extent)
else:
try:
user_config = getattr(request.user, 'config', None)
if user_config:
# Récupérer les contrats accessibles ayant des communes associées
contracts_to_check = []
if user_config.default_contract and user_config.default_contract.municipalities.exists():
contracts_to_check.append(user_config.default_contract)
else:
contracts_to_check = list(user_config.contracts.prefetch_related('municipalities').filter(municipalities__isnull=False).distinct())
geoms_4326 = []
for c in contracts_to_check:
for m in c.municipalities.exclude(geom=None):
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
geoms_4326.append(g)
if geoms_4326:
min_lon = min(g.extent[0] for g in geoms_4326)
min_lat = min(g.extent[1] for g in geoms_4326)
max_lon = max(g.extent[2] for g in geoms_4326)
max_lat = max(g.extent[3] for g in geoms_4326)
# Léger buffer (5% d'expansion de chaque côté pour aérer le cadre)
delta_lon = (max_lon - min_lon) * 0.05
delta_lat = (max_lat - min_lat) * 0.05
center = [round((min_lon + max_lon) / 2.0, 5), round((min_lat + max_lat) / 2.0, 5)]
extent = [
[round(min_lon - delta_lon, 5), round(min_lat - delta_lat, 5)],
[round(max_lon + delta_lon, 5), round(max_lat + delta_lat, 5)]
]
span = max(max_lon - min_lon, max_lat - min_lat)
if span < 0.08:
zoom = 14
elif span < 0.2:
zoom = 13
elif span < 0.5:
zoom = 12
else:
zoom = 11
if session is not None:
session[SESSION_MAP_BOUNDS_KEY] = {
'user_id': request.user.pk,
'center': center,
'zoom': zoom,
'extent': extent,
}
except Exception:
pass
return {
'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'),
'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'),
'organization_logo_header': getattr(settings, 'ORGANIZATION_LOGO_HEADER', 'common/android-192.png'),
'organization_primary_color': getattr(settings, 'ORGANIZATION_PRIMARY_COLOR_HEX', '#2f3e8b'),
'map_default_center': [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)],
'map_default_zoom': getattr(settings, 'MAP_DEFAULT_ZOOM', 11),
'map_default_extent': getattr(settings, 'MAP_DEFAULT_EXTENT', None),
'map_default_center': center,
'map_default_zoom': zoom,
'map_default_extent': extent,
'map_overlay_wms_url': getattr(settings, 'MAP_OVERLAY_WMS_URL', ''),
'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''),
'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''),

View file

@ -390,8 +390,55 @@ class ScheduledCommandResolveOutputFileTests(TestCase):
mock_attach.assert_called_once_with(fake_path)
mock_send.assert_called_once()
from django.test import RequestFactory
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.cache import cache
from common.context_processors import (
current_app_view,
organization_info,
SESSION_MAP_BOUNDS_KEY,
)
from common.models import AppView
class ContextProcessorsCachingTests(TestCase):
def setUp(self):
cache.clear()
self.factory = RequestFactory()
self.user = User.objects.create_user(username="context_user", password="password123")
self.config = UserConfig.objects.create(user=self.user)
def _add_session_to_request(self, request):
middleware = SessionMiddleware(lambda req: None)
middleware.process_request(request)
request.session.save()
def test_current_app_view_caches_result(self):
AppView.objects.create(code="asset_management", name_fr="Gestion des actifs", name_nl="Activa beheer", url_name="home:index")
request = self.factory.get("/")
with self.assertNumQueries(1):
ctx1 = current_app_view(request)
self.assertIsNotNone(ctx1["current_view"])
# Second call should use cache and perform 0 database queries
with self.assertNumQueries(0):
ctx2 = current_app_view(request)
self.assertEqual(ctx1["current_view"], ctx2["current_view"])
def test_organization_info_caches_in_session(self):
request = self.factory.get("/")
self._add_session_to_request(request)
request.user = self.user
ctx1 = organization_info(request)
self.assertIn(SESSION_MAP_BOUNDS_KEY, request.session)
self.assertEqual(request.session[SESSION_MAP_BOUNDS_KEY]["user_id"], self.user.pk)
# Subsequent call with session already containing user_map_bounds does not query or recalculate
with self.assertNumQueries(0):
ctx2 = organization_info(request)
self.assertEqual(ctx1["map_default_center"], ctx2["map_default_center"])
self.assertEqual(ctx1["map_default_zoom"], ctx2["map_default_zoom"])
self.assertEqual(ctx1["map_default_extent"], ctx2["map_default_extent"])

View file

@ -22,6 +22,7 @@ from urllib.parse import urlencode
from common.context_processors import (
SESSION_ALLOWED_IDS_KEY,
SESSION_ORIGIN_ID_KEY,
SESSION_MAP_BOUNDS_KEY,
compute_initial_allowed_ids,
)
@ -287,6 +288,7 @@ def switch_account(request):
session[SESSION_ALLOWED_IDS_KEY] = allowed_ids
session[SESSION_ORIGIN_ID_KEY] = origin_user_id
session.pop(SESSION_MAP_BOUNDS_KEY, None)
return redirect(origin_next or reverse("home:index"))