refactor: migrate user map bounds caching from session to Django cache with signal-based invalidation
This commit is contained in:
parent
73738b6757
commit
cc0facdf92
4 changed files with 110 additions and 20 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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] = {
|
'center': center,
|
||||||
'user_id': request.user.pk,
|
'zoom': zoom,
|
||||||
'center': center,
|
'extent': extent,
|
||||||
'zoom': zoom,
|
}, 86400)
|
||||||
'extent': extent,
|
|
||||||
}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
37
loko/common/signals.py
Normal file
37
loko/common/signals.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue