refactor: migrate user map bounds caching from session to Django cache with signal-based invalidation

This commit is contained in:
kdeterme 2026-09-19 18:40:37 +02:00
parent 73738b6757
commit cc0facdf92
4 changed files with 110 additions and 20 deletions

View file

@ -11,6 +11,9 @@ class CommonConfig(AppConfig):
from .middleware import set_login_time from .middleware import set_login_time
user_logged_in.connect(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 # Charger les extensions conditionnelles
try: try:
from common import bm_extensions from common import bm_extensions

View file

@ -12,7 +12,6 @@ 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
@ -98,7 +97,6 @@ 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,
@ -109,7 +107,6 @@ 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,
@ -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): 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 Dynamically adapts map_default_center, map_default_zoom and map_default_extent
when the authenticated user has an assigned contract associated with a municipality. 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)] center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)]
zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11) zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11)
extent = getattr(settings, 'MAP_DEFAULT_EXTENT', None) extent = getattr(settings, 'MAP_DEFAULT_EXTENT', None)
if hasattr(request, 'user') and request.user.is_authenticated: if hasattr(request, 'user') and request.user.is_authenticated:
session = getattr(request, 'session', None) cache_key = f"user_map_bounds_{request.user.pk}"
cached_bounds = session.get(SESSION_MAP_BOUNDS_KEY) if session is not None else None 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) center = cached_bounds.get('center', center)
zoom = cached_bounds.get('zoom', zoom) zoom = cached_bounds.get('zoom', zoom)
extent = cached_bounds.get('extent', extent) extent = cached_bounds.get('extent', extent)
@ -211,13 +217,11 @@ def organization_info(request):
else: else:
zoom = 11 zoom = 11
if session is not None: cache.set(cache_key, {
session[SESSION_MAP_BOUNDS_KEY] = {
'user_id': request.user.pk,
'center': center, 'center': center,
'zoom': zoom, 'zoom': zoom,
'extent': extent, 'extent': extent,
} }, 86400)
except Exception: except Exception:
pass pass

37
loko/common/signals.py Normal file
View file

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

View file

@ -396,7 +396,6 @@ from django.core.cache import cache
from common.context_processors import ( from common.context_processors import (
current_app_view, current_app_view,
organization_info, organization_info,
SESSION_MAP_BOUNDS_KEY,
) )
from common.models import AppView from common.models import AppView
@ -426,19 +425,66 @@ class ContextProcessorsCachingTests(TestCase):
ctx2 = current_app_view(request) ctx2 = current_app_view(request)
self.assertEqual(ctx1["current_view"], ctx2["current_view"]) 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("/") request = self.factory.get("/")
self._add_session_to_request(request) self._add_session_to_request(request)
request.user = self.user request.user = self.user
ctx1 = organization_info(request) cache_key = f"user_map_bounds_{self.user.pk}"
self.assertIn(SESSION_MAP_BOUNDS_KEY, request.session) self.assertIsNone(cache.get(cache_key))
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 ctx1 = organization_info(request)
self.assertIsNotNone(cache.get(cache_key))
# Subsequent call with cache populated performs 0 queries
with self.assertNumQueries(0): with self.assertNumQueries(0):
ctx2 = organization_info(request) ctx2 = organization_info(request)
self.assertEqual(ctx1["map_default_center"], ctx2["map_default_center"]) 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_zoom"], ctx2["map_default_zoom"])
self.assertEqual(ctx1["map_default_extent"], ctx2["map_default_extent"]) 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))