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 typing import Iterable, List
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
from django.urls import resolve from django.urls import resolve
from .models import UserConfig, AppView from .models import UserConfig, AppView
@ -11,6 +12,7 @@ from .models import UserConfig, AppView
SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids" SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids"
SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id" SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id"
SESSION_MAP_BOUNDS_KEY = "user_map_bounds"
from django.conf import settings from django.conf import settings
@ -55,6 +57,7 @@ def current_app_view(request):
Only 'sign' and 'documents' have their own dedicated views. Only 'sign' and 'documents' have their own dedicated views.
All other apps (assets, interventions, controls, contracts, etc.) use 'asset_management' view. 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 current_view = None
@ -67,13 +70,20 @@ def current_app_view(request):
namespace = resolved.namespace # e.g., 'sign', 'home', 'assets' namespace = resolved.namespace # e.g., 'sign', 'home', 'assets'
if namespace: if namespace:
if namespace in DEDICATED_VIEW_APPS: cache_key = f"appview_for_{namespace}"
# These apps have their own dedicated view cached_view = cache.get(cache_key)
current_view = AppView.objects.filter(url_name__startswith=f"{namespace}:").first() if cached_view is not None:
current_view = None if cached_view is False else cached_view
else: else:
# All other apps (home, assets, interventions, controls, contracts, etc.) if namespace in DEDICATED_VIEW_APPS:
# should show 'asset_management' view # These apps have their own dedicated view
current_view = AppView.objects.filter(code='asset_management').first() 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: except Exception:
pass pass
@ -88,6 +98,7 @@ def linked_accounts(request):
if not user or isinstance(user, AnonymousUser) or not user.is_authenticated: if not user or isinstance(user, AnonymousUser) or not user.is_authenticated:
request.session.pop(SESSION_ALLOWED_IDS_KEY, None) request.session.pop(SESSION_ALLOWED_IDS_KEY, None)
request.session.pop(SESSION_ORIGIN_ID_KEY, None) request.session.pop(SESSION_ORIGIN_ID_KEY, None)
request.session.pop(SESSION_MAP_BOUNDS_KEY, None)
return { return {
"switchable_accounts": [], "switchable_accounts": [],
"account_switch_origin_user_id": None, "account_switch_origin_user_id": None,
@ -98,6 +109,7 @@ def linked_accounts(request):
except UserConfig.DoesNotExist: except UserConfig.DoesNotExist:
request.session.pop(SESSION_ALLOWED_IDS_KEY, None) request.session.pop(SESSION_ALLOWED_IDS_KEY, None)
request.session.pop(SESSION_ORIGIN_ID_KEY, None) request.session.pop(SESSION_ORIGIN_ID_KEY, None)
request.session.pop(SESSION_MAP_BOUNDS_KEY, None)
return { return {
"switchable_accounts": [], "switchable_accounts": [],
"account_switch_origin_user_id": None, "account_switch_origin_user_id": None,
@ -140,15 +152,83 @@ def linked_accounts(request):
def organization_info(request): def organization_info(request):
""" """
Context processor that exposes organization details from settings. 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 { return {
'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'), 'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'),
'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'), 'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'),
'organization_logo_header': getattr(settings, 'ORGANIZATION_LOGO_HEADER', 'common/android-192.png'), 'organization_logo_header': getattr(settings, 'ORGANIZATION_LOGO_HEADER', 'common/android-192.png'),
'organization_primary_color': getattr(settings, 'ORGANIZATION_PRIMARY_COLOR_HEX', '#2f3e8b'), '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_center': center,
'map_default_zoom': getattr(settings, 'MAP_DEFAULT_ZOOM', 11), 'map_default_zoom': zoom,
'map_default_extent': getattr(settings, 'MAP_DEFAULT_EXTENT', None), 'map_default_extent': extent,
'map_overlay_wms_url': getattr(settings, 'MAP_OVERLAY_WMS_URL', ''), 'map_overlay_wms_url': getattr(settings, 'MAP_OVERLAY_WMS_URL', ''),
'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''), 'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''),
'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''), '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_attach.assert_called_once_with(fake_path)
mock_send.assert_called_once() 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 ( from common.context_processors import (
SESSION_ALLOWED_IDS_KEY, SESSION_ALLOWED_IDS_KEY,
SESSION_ORIGIN_ID_KEY, SESSION_ORIGIN_ID_KEY,
SESSION_MAP_BOUNDS_KEY,
compute_initial_allowed_ids, compute_initial_allowed_ids,
) )
@ -287,6 +288,7 @@ def switch_account(request):
session[SESSION_ALLOWED_IDS_KEY] = allowed_ids session[SESSION_ALLOWED_IDS_KEY] = allowed_ids
session[SESSION_ORIGIN_ID_KEY] = origin_user_id session[SESSION_ORIGIN_ID_KEY] = origin_user_id
session.pop(SESSION_MAP_BOUNDS_KEY, None)
return redirect(origin_next or reverse("home:index")) return redirect(origin_next or reverse("home:index"))