From 23bc60c4b90439403fe0580e8509eea1d5204533 Mon Sep 17 00:00:00 2001 From: "justiandev@gmail.com" Date: Wed, 8 Jul 2026 13:03:43 +0200 Subject: [PATCH] fix: area cache invalidation for routing expressions - Ensure invalidation whenever an Area or AreaType instance is created, updated, deleted or imported --- app/mypy-baseline.txt | 5 + app/signals/apps/services/domain/dsl.py | 6 +- app/signals/apps/signals/apps.py | 1 + app/signals/apps/signals/area_cache.py | 17 +++ app/signals/apps/signals/area_receivers.py | 27 ++++ .../tests/test_area_cache_invalidation.py | 135 ++++++++++++++++++ 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 app/signals/apps/signals/area_cache.py create mode 100644 app/signals/apps/signals/area_receivers.py create mode 100644 app/signals/apps/signals/tests/test_area_cache_invalidation.py diff --git a/app/mypy-baseline.txt b/app/mypy-baseline.txt index 41914d505..c4130985e 100644 --- a/app/mypy-baseline.txt +++ b/app/mypy-baseline.txt @@ -131,3 +131,8 @@ signals/apps/api/views/category.py:0: error: Skipping analyzing "rest_framework_ signals/apps/api/views/attachment.py:0: error: Skipping analyzing "rest_framework_extensions.mixins": module is installed, but missing library stubs or py.typed marker [import-untyped] signals/apps/api/views/signals/public/signals.py:0: error: "type[PublicSignalGeographyFeature]" has no attribute "objects" [attr-defined] signals/apps/api/views/signals/private/signals.py:0: error: Skipping analyzing "rest_framework_extensions.mixins": module is installed, but missing library stubs or py.typed marker [import-untyped] +signals/apps/signals/area_receivers.py:0: error: Library stubs not installed for "import_export.signals" [import-untyped] +signals/apps/signals/tests/test_area_cache_invalidation.py:0: error: Library stubs not installed for "import_export.signals" [import-untyped] +signals/apps/signals/tests/test_area_cache_invalidation.py:0: note: Hint: "python3 -m pip install types-django-import-export" +signals/apps/signals/tests/test_area_cache_invalidation.py:0: note: (or run "mypy --install-types" to install all missing stub packages) +signals/apps/signals/tests/test_area_cache_invalidation.py:0: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports \ No newline at end of file diff --git a/app/signals/apps/services/domain/dsl.py b/app/signals/apps/services/domain/dsl.py index 8ba5d08d8..0e9195972 100644 --- a/app/signals/apps/services/domain/dsl.py +++ b/app/signals/apps/services/domain/dsl.py @@ -6,6 +6,7 @@ from signals.apps.dsl.evaluators.evaluator import Evaluator from signals.apps.dsl.ExpressionEvaluator import ExpressionEvaluator +from signals.apps.signals.area_cache import get_area_cache_version from signals.apps.signals.managers import SignalManager from signals.apps.signals.models import Area, AreaType, RoutingExpression, Signal @@ -34,6 +35,7 @@ def validate(self, context, code: str) -> str | None: # maps signal object to context dictionary class SignalContext: _areas = None + _areas_version = None def _init_areas(self): tmp = {} @@ -45,8 +47,10 @@ def _init_areas(self): @property def areas(self): - if not self._areas: + version = get_area_cache_version() + if not self._areas or self._areas_version != version: self._areas = self._init_areas() + self._areas_version = version return self._areas def __call__(self, signal: Signal): diff --git a/app/signals/apps/signals/apps.py b/app/signals/apps/signals/apps.py index 59fc3799e..b206475a6 100644 --- a/app/signals/apps/signals/apps.py +++ b/app/signals/apps/signals/apps.py @@ -9,5 +9,6 @@ class SignalsConfig(AppConfig): def ready(self): # Import Django signals to connect receiver functions. + import signals.apps.signals.area_receivers # noqa import signals.apps.signals.signal_receivers # noqa import signals.apps.signals.models.permission # noqa diff --git a/app/signals/apps/signals/area_cache.py b/app/signals/apps/signals/area_cache.py new file mode 100644 index 000000000..5f4805a9b --- /dev/null +++ b/app/signals/apps/signals/area_cache.py @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2026 Delta10 B.V. + +from uuid import uuid4 + +from django.core.cache import cache + +AREA_CACHE_VERSION_KEY = 'signal_context_areas_version' +AREA_CACHE_DEFAULT_VERSION = 'initial' + + +def get_area_cache_version() -> str: + return cache.get(AREA_CACHE_VERSION_KEY, AREA_CACHE_DEFAULT_VERSION) + + +def invalidate_area_cache() -> None: + cache.set(AREA_CACHE_VERSION_KEY, uuid4().hex, timeout=None) diff --git a/app/signals/apps/signals/area_receivers.py b/app/signals/apps/signals/area_receivers.py new file mode 100644 index 000000000..52bed70dd --- /dev/null +++ b/app/signals/apps/signals/area_receivers.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2026 Delta10 B.V. + +from django.db import transaction +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver +from import_export.signals import post_import + +from signals.apps.signals.area_cache import invalidate_area_cache +from signals.apps.signals.models import Area, AreaType + + +@receiver(post_save, sender=Area, dispatch_uid='invalidate_area_cache_area_save') +@receiver(post_delete, sender=Area, dispatch_uid='invalidate_area_cache_area_delete') +@receiver(post_save, sender=AreaType, dispatch_uid='invalidate_area_cache_area_type_save') +@receiver(post_delete, sender=AreaType, dispatch_uid='invalidate_area_cache_area_type_delete') +def invalidate_signal_context_area_cache(sender, **kwargs) -> None: + transaction.on_commit(invalidate_area_cache) + + +@receiver(post_import, dispatch_uid='invalidate_area_cache_after_area_import') +def invalidate_signal_context_area_cache_after_import(sender, model, **kwargs) -> None: + # django-import-export sends post_import with sender=None and model=. + if model not in (Area, AreaType): + return + + transaction.on_commit(invalidate_area_cache) diff --git a/app/signals/apps/signals/tests/test_area_cache_invalidation.py b/app/signals/apps/signals/tests/test_area_cache_invalidation.py new file mode 100644 index 000000000..87d706068 --- /dev/null +++ b/app/signals/apps/signals/tests/test_area_cache_invalidation.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2026 Delta10 B.V. + +from django.contrib.gis import geos +from django.core.cache import cache +from django.test import TestCase, override_settings +from import_export.signals import post_import + +from signals.apps.services.domain.dsl import SignalContext, SignalDslService +from signals.apps.signals.area_cache import get_area_cache_version +from signals.apps.signals.factories import ( + AreaFactory, + AreaTypeFactory, + DepartmentFactory, + ExpressionFactory, + ExpressionTypeFactory, + RoutingExpressionFactory, + SignalFactory +) +from signals.apps.signals.models import Area, AreaType + + +@override_settings(CACHES={ + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'area-cache-invalidation-tests', + } +}) +class AreaCacheInvalidationTest(TestCase): + def setUp(self): + cache.clear() + + def test_area_save_invalidates_area_cache_version(self): + area = AreaFactory.create() + version_before = get_area_cache_version() + + area.name = 'Updated area' + with self.captureOnCommitCallbacks(execute=True): + area.save() + + self.assertNotEqual(get_area_cache_version(), version_before) + + def test_area_delete_invalidates_area_cache_version(self): + area = AreaFactory.create() + version_before = get_area_cache_version() + + with self.captureOnCommitCallbacks(execute=True): + area.delete() + + self.assertNotEqual(get_area_cache_version(), version_before) + + def test_area_post_import_invalidates_area_cache_version(self): + version_before = get_area_cache_version() + + with self.captureOnCommitCallbacks(execute=True): + post_import.send(sender=None, model=Area) + + self.assertNotEqual(get_area_cache_version(), version_before) + + def test_area_type_save_invalidates_area_cache_version(self): + area_type = AreaTypeFactory.create() + version_before = get_area_cache_version() + + area_type.name = 'Updated area type' + with self.captureOnCommitCallbacks(execute=True): + area_type.save() + + self.assertNotEqual(get_area_cache_version(), version_before) + + def test_area_type_delete_invalidates_area_cache_version(self): + area_type = AreaTypeFactory.create() + version_before = get_area_cache_version() + + with self.captureOnCommitCallbacks(execute=True): + area_type.delete() + + self.assertNotEqual(get_area_cache_version(), version_before) + + def test_area_type_post_import_invalidates_area_cache_version(self): + version_before = get_area_cache_version() + + with self.captureOnCommitCallbacks(execute=True): + post_import.send(sender=None, model=AreaType) + + self.assertNotEqual(get_area_cache_version(), version_before) + + def test_signal_context_reloads_areas_after_invalidation(self): + area = AreaFactory.create(code='old-code', _type__name='gebied', _type__code='gebied') + context = SignalContext() + + self.assertIn('old-code', context.areas['gebied']) + + area.code = 'new-code' + with self.captureOnCommitCallbacks(execute=True): + area.save() + + self.assertIn('new-code', context.areas['gebied']) + self.assertNotIn('old-code', context.areas['gebied']) + + def test_routing_uses_updated_area_without_recreating_service(self): + area = AreaFactory.create( + geometry=geos.MultiPolygon([geos.Polygon.from_bbox([1.0, 1.0, 2.0, 2.0])], srid=4326), + name='centrum', + code='centrum', + _type__name='gebied', + _type__code='gebied' + ) + expression_type = ExpressionTypeFactory.create(name='routing') + department = DepartmentFactory.create() + expression = ExpressionFactory.create( + _type=expression_type, + code='location in areas."gebied"."centrum"' + ) + RoutingExpressionFactory.create( + _expression=expression, + _department=department, + is_active=True, + order=1 + ) + signal = SignalFactory.create(location__geometrie=geos.Point(4.88, 52.36, srid=4326)) + service = SignalDslService() + service.context_func = SignalContext() + + self.assertFalse(service.process_routing_rules(signal)) + signal.refresh_from_db() + self.assertIsNone(signal.routing_assignment) + + area.geometry = geos.MultiPolygon([geos.Polygon.from_bbox([4.87, 52.35, 4.89, 52.37])], srid=4326) + with self.captureOnCommitCallbacks(execute=True): + area.save() + + self.assertTrue(service.process_routing_rules(signal)) + signal.refresh_from_db() + self.assertIsNotNone(signal.routing_assignment) + self.assertEqual(signal.routing_assignment.departments.first(), department)