From e68e6ccefe267fd42d24867ffa65e1db0fb274b1 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sat, 19 Sep 2026 18:33:17 +0200 Subject: [PATCH] feat: optimize context processing --- loko/common/context_processors.py | 98 ++++++++++++++++++++++++++++--- loko/common/tests.py | 47 +++++++++++++++ loko/common/views.py | 2 + 3 files changed, 138 insertions(+), 9 deletions(-) diff --git a/loko/common/context_processors.py b/loko/common/context_processors.py index 017c14c..4ce10de 100644 --- a/loko/common/context_processors.py +++ b/loko/common/context_processors.py @@ -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', ''), diff --git a/loko/common/tests.py b/loko/common/tests.py index 357d5ad..e02e49d 100644 --- a/loko/common/tests.py +++ b/loko/common/tests.py @@ -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"]) diff --git a/loko/common/views.py b/loko/common/views.py index 71a456b..1820f05 100644 --- a/loko/common/views.py +++ b/loko/common/views.py @@ -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"))