From cc0facdf928ba30efe343f3fc2f4c1927c4813db Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sat, 19 Sep 2026 18:40:37 +0200 Subject: [PATCH] refactor: migrate user map bounds caching from session to Django cache with signal-based invalidation --- loko/common/apps.py | 3 ++ loko/common/context_processors.py | 32 +++++++++-------- loko/common/signals.py | 37 ++++++++++++++++++++ loko/common/tests.py | 58 +++++++++++++++++++++++++++---- 4 files changed, 110 insertions(+), 20 deletions(-) create mode 100644 loko/common/signals.py diff --git a/loko/common/apps.py b/loko/common/apps.py index be8d2e7..af4704a 100644 --- a/loko/common/apps.py +++ b/loko/common/apps.py @@ -11,6 +11,9 @@ class CommonConfig(AppConfig): from .middleware import set_login_time user_logged_in.connect(set_login_time) + # Connecter les signaux de common (ex: invalidation de cache) + import common.signals # noqa + # Charger les extensions conditionnelles try: from common import bm_extensions diff --git a/loko/common/context_processors.py b/loko/common/context_processors.py index 4ce10de..df16535 100644 --- a/loko/common/context_processors.py +++ b/loko/common/context_processors.py @@ -12,7 +12,6 @@ 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 @@ -98,7 +97,6 @@ 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, @@ -109,7 +107,6 @@ 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, @@ -149,22 +146,31 @@ def linked_accounts(request): } +def invalidate_user_map_bounds(user_id: int) -> None: + """ + Invalidates the cached map center/zoom/extent for a given user. + Called when UserConfig or UserContractAccess changes. + """ + if user_id: + cache.delete(f"user_map_bounds_{user_id}") + + 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. + Caches calculated bounds in Django cache 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 + cache_key = f"user_map_bounds_{request.user.pk}" + cached_bounds = cache.get(cache_key) - if cached_bounds and cached_bounds.get('user_id') == request.user.pk: + if cached_bounds is not None: center = cached_bounds.get('center', center) zoom = cached_bounds.get('zoom', zoom) extent = cached_bounds.get('extent', extent) @@ -211,13 +217,11 @@ def organization_info(request): else: zoom = 11 - if session is not None: - session[SESSION_MAP_BOUNDS_KEY] = { - 'user_id': request.user.pk, - 'center': center, - 'zoom': zoom, - 'extent': extent, - } + cache.set(cache_key, { + 'center': center, + 'zoom': zoom, + 'extent': extent, + }, 86400) except Exception: pass diff --git a/loko/common/signals.py b/loko/common/signals.py new file mode 100644 index 0000000..5ee70e6 --- /dev/null +++ b/loko/common/signals.py @@ -0,0 +1,37 @@ +import logging +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver + +from .models import UserConfig, UserContractAccess +from .context_processors import invalidate_user_map_bounds + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=UserConfig) +@receiver(post_delete, sender=UserConfig) +def invalidate_map_bounds_on_userconfig_change(sender, instance, **kwargs): + """ + Invalidates the cached map center/zoom/extent when UserConfig is modified or deleted + (e.g., when default_contract changes). + """ + if instance.user_id: + invalidate_user_map_bounds(instance.user_id) + + +@receiver(post_save, sender=UserContractAccess) +@receiver(post_delete, sender=UserContractAccess) +def invalidate_map_bounds_on_contract_access_change(sender, instance, **kwargs): + """ + Invalidates the cached map center/zoom/extent when user contract accesses are added or removed. + """ + try: + user_id = instance.user_config.user_id if instance.user_config else None + if user_id: + invalidate_user_map_bounds(user_id) + except Exception as e: + logger.warning( + "Failed to invalidate map bounds for UserContractAccess %s: %s", + getattr(instance, 'pk', None), + e + ) diff --git a/loko/common/tests.py b/loko/common/tests.py index e02e49d..a2105a4 100644 --- a/loko/common/tests.py +++ b/loko/common/tests.py @@ -396,7 +396,6 @@ 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 @@ -426,19 +425,66 @@ class ContextProcessorsCachingTests(TestCase): ctx2 = current_app_view(request) self.assertEqual(ctx1["current_view"], ctx2["current_view"]) - def test_organization_info_caches_in_session(self): + def test_organization_info_caches_in_django_cache(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) + cache_key = f"user_map_bounds_{self.user.pk}" + self.assertIsNone(cache.get(cache_key)) - # Subsequent call with session already containing user_map_bounds does not query or recalculate + ctx1 = organization_info(request) + self.assertIsNotNone(cache.get(cache_key)) + + # Subsequent call with cache populated performs 0 queries 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"]) + def test_user_config_save_invalidates_cache(self): + request = self.factory.get("/") + self._add_session_to_request(request) + request.user = self.user + + cache_key = f"user_map_bounds_{self.user.pk}" + organization_info(request) + self.assertIsNotNone(cache.get(cache_key)) + + # Modifying and saving UserConfig should invalidate the cached map bounds via signal + self.config.save() + self.assertIsNone(cache.get(cache_key)) + + def test_user_contract_access_invalidates_cache(self): + from datetime import date + from contracts.models import Contract, Company + from common.models import UserContractAccess + + company = Company.objects.create(name="Company Test") + contract = Contract.objects.create( + company=company, + contract_number="CTR-TEST-CACHE", + start_date=date(2026, 1, 1), + end_date=date(2026, 12, 31), + ) + request = self.factory.get("/") + self._add_session_to_request(request) + request.user = self.user + + cache_key = f"user_map_bounds_{self.user.pk}" + organization_info(request) + self.assertIsNotNone(cache.get(cache_key)) + + # Creating UserContractAccess invalidates cache + access = UserContractAccess.objects.create(user_config=self.config, contract=contract) + self.assertIsNone(cache.get(cache_key)) + + # Re-populate cache + organization_info(request) + self.assertIsNotNone(cache.get(cache_key)) + + # Deleting UserContractAccess invalidates cache + access.delete() + self.assertIsNone(cache.get(cache_key)) +