Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/mypy-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion app/signals/apps/services/domain/dsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = {}
Expand All @@ -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):
Expand Down
1 change: 1 addition & 0 deletions app/signals/apps/signals/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions app/signals/apps/signals/area_cache.py
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 27 additions & 0 deletions app/signals/apps/signals/area_receivers.py
Original file line number Diff line number Diff line change
@@ -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=<imported model>.
if model not in (Area, AreaType):
return

transaction.on_commit(invalidate_area_cache)
135 changes: 135 additions & 0 deletions app/signals/apps/signals/tests/test_area_cache_invalidation.py
Original file line number Diff line number Diff line change
@@ -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)
Loading