diff --git a/openwisp_notifications/base/models.py b/openwisp_notifications/base/models.py index 002611fa..9df10a1f 100644 --- a/openwisp_notifications/base/models.py +++ b/openwisp_notifications/base/models.py @@ -62,6 +62,18 @@ def notification_render_attributes(obj, **attrs): } defaults.update(attrs) + db_verb = obj.verb + + config = {} + if obj.type: + try: + config = get_notification_configuration(obj.type) + except NotificationRenderException as e: + logger.error( + "Couldn't get notification config for type %s : %s", obj.type, e + ) + obj.verb = db_verb if db_verb is not None else config.get("verb") + for target_attr, source_attr in defaults.items(): setattr(obj, target_attr, getattr(obj, source_attr)) @@ -75,9 +87,11 @@ def notification_render_attributes(obj, **attrs): setattr(obj, "target", obj._related_object("target")) yield obj + obj.verb = db_verb for attr in defaults.keys(): - delattr(obj, attr) + if hasattr(obj, attr): + delattr(obj, attr) class AbstractNotification(UUIDModel, BaseNotification): @@ -283,23 +297,28 @@ def get_message(self): @cached_property def email_subject(self): - if self.type: - try: - config = get_notification_configuration(self.type) - data = self.data or {} - return config["email_subject"].format( - site=Site.objects.get_current(), notification=self, **data - ) - except (AttributeError, KeyError, NotificationRenderException) as exception: - self._invalid_notification( - self.pk, - exception, - "Error encountered in generating notification email", - ) - elif self.data.get("email_subject", None): - return self.data.get("email_subject") - else: - return self.message + with notification_render_attributes(self): + if self.type: + try: + config = get_notification_configuration(self.type) + data = self.data or {} + return config["email_subject"].format( + site=Site.objects.get_current(), notification=self, **data + ) + except ( + AttributeError, + KeyError, + NotificationRenderException, + ) as exception: + self._invalid_notification( + self.pk, + exception, + "Error encountered in generating notification email", + ) + elif self.data.get("email_subject", None): + return self.data.get("email_subject") + else: + return self.message def _related_object(self, field): obj_id = getattr(self, f"{field}_object_id") diff --git a/openwisp_notifications/base/notifications.py b/openwisp_notifications/base/notifications.py index 26917f81..cfc4ff26 100644 --- a/openwisp_notifications/base/notifications.py +++ b/openwisp_notifications/base/notifications.py @@ -50,7 +50,7 @@ class AbstractNotification(models.Model): actor = GenericForeignKey("actor_content_type", "actor_object_id") actor.short_description = _("actor") - verb = models.CharField(_("verb"), max_length=255) + verb = models.CharField(_("verb"), max_length=255, null=True, blank=True) description = models.TextField(_("description"), blank=True, null=True) target_content_type = models.ForeignKey( diff --git a/openwisp_notifications/handlers.py b/openwisp_notifications/handlers.py index 1d506398..ea740ad1 100644 --- a/openwisp_notifications/handlers.py +++ b/openwisp_notifications/handlers.py @@ -65,7 +65,6 @@ def notify_handler(**kwargs): level = kwargs.pop( "level", notification_template.get("level", Notification.LEVELS.info) ) - verb = notification_template.get("verb", kwargs.pop("verb", None)) user_app_name = User._meta.app_label where = Q(is_superuser=True) @@ -144,7 +143,6 @@ def notify_handler(**kwargs): notification = Notification( recipient=recipient, actor=actor, - verb=str(verb), public=public, description=description, timestamp=timestamp, diff --git a/openwisp_notifications/migrations/0012_alter_notification_verb.py b/openwisp_notifications/migrations/0012_alter_notification_verb.py new file mode 100644 index 00000000..3b049015 --- /dev/null +++ b/openwisp_notifications/migrations/0012_alter_notification_verb.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.6 on 2025-10-14 18:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("openwisp_notifications", "0011_populate_organizationnotificationsettings"), + ] + + operations = [ + migrations.AlterField( + model_name="notification", + name="verb", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="verb" + ), + ), + ] diff --git a/openwisp_notifications/migrations/0013_merge_20260131_1927.py b/openwisp_notifications/migrations/0013_merge_20260131_1927.py new file mode 100644 index 00000000..dd592e27 --- /dev/null +++ b/openwisp_notifications/migrations/0013_merge_20260131_1927.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.6 on 2026-01-31 18:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("openwisp_notifications", "0012_alter_notification_verb"), + ("openwisp_notifications", "0012_replace_jsonfield_with_django_builtin"), + ] + + operations = [] diff --git a/openwisp_notifications/tests/test_notifications.py b/openwisp_notifications/tests/test_notifications.py index 71f5fc5d..15c61efe 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -24,6 +24,7 @@ from openwisp_notifications import settings as app_settings from openwisp_notifications import tasks, utils +from openwisp_notifications.base.models import notification_render_attributes from openwisp_notifications.exceptions import NotificationRenderException from openwisp_notifications.handlers import ( notify_handler, @@ -431,7 +432,8 @@ def test_default_notification_type(self): self._create_notification() n = notification_queryset.first() self.assertEqual(n.level, "info") - self.assertEqual(n.verb, "default verb") + with notification_render_attributes(n) as rendered: + self.assertEqual(rendered.verb, "default verb") self.assertIn( "Default notification with default verb and level info by", n.message ) @@ -494,7 +496,8 @@ def test_generic_notification_type(self): self._create_notification() n = notification_queryset.first() self.assertEqual(n.level, "info") - self.assertEqual(n.verb, "generic verb") + with notification_render_attributes(n) as rendered: + self.assertEqual(rendered.verb, "generic verb") expected_output = ( '
' ).format( @@ -607,7 +610,8 @@ def test_register_unregister_notification_type(self): self._create_notification() n = notification_queryset.first() self.assertEqual(n.level, "test") - self.assertEqual(n.verb, "testing") + with notification_render_attributes(n) as rendered: + self.assertEqual(rendered.verb, "testing") self.assertEqual( n.message, "testing initiated by admin since 0\xa0minutes
", @@ -1530,6 +1534,49 @@ def test_notification_preference_page(self): response = self.client.get(reverse(preference_page, args=(uuid4(),))) self.assertEqual(response.status_code, 404) + @mock_notification_types + def test_dynamic_verb_changed(self): + self.notification_options.update( + {"type": "default", "target": self._get_org_user()} + ) + default_config = get_notification_configuration("default") + original_message = default_config["message"] + original_verb = default_config.get("verb", "default verb") + default_config["message"] = "Notification with {notification.verb}" + default_config["verb"] = "initial verb" + + self._create_notification() + notification = notification_queryset.first() + + with self.subTest("DB does not store default verb"): + self.assertIsNone(notification.verb) + + with self.subTest("Initial config verb is rendered"): + with notification_render_attributes(notification) as n: + self.assertEqual(n.verb, "initial verb") + self.assertIn("initial verb", n.message) + + default_config["verb"] = "updated verb" + del notification.message + + with self.subTest("Config change affects existing notification"): + with notification_render_attributes(notification) as n: + self.assertEqual(n.verb, "updated verb") + self.assertIn("updated verb", n.message) + + notification.verb = "db verb" + notification.save() + notification.refresh_from_db() + del notification.message + + with self.subTest("DB verb overrides config"): + with notification_render_attributes(notification) as n: + self.assertEqual(n.verb, "db verb") + self.assertIn("db verb", n.message) + + default_config["message"] = original_message + default_config["verb"] = original_verb + class TestTransactionNotifications(TestOrganizationMixin, TransactionTestCase): def setUp(self): diff --git a/tests/openwisp2/sample_notifications/migrations/0004_alter_notification_verb.py b/tests/openwisp2/sample_notifications/migrations/0004_alter_notification_verb.py new file mode 100644 index 00000000..900e6ce5 --- /dev/null +++ b/tests/openwisp2/sample_notifications/migrations/0004_alter_notification_verb.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.6 on 2025-10-14 23:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sample_notifications", "0003_default_groups_permissions"), + ] + + operations = [ + migrations.AlterField( + model_name="notification", + name="verb", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="verb" + ), + ), + ] diff --git a/tests/openwisp2/sample_notifications/migrations/0005_merge_20260131_1929.py b/tests/openwisp2/sample_notifications/migrations/0005_merge_20260131_1929.py new file mode 100644 index 00000000..12ab73cf --- /dev/null +++ b/tests/openwisp2/sample_notifications/migrations/0005_merge_20260131_1929.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.6 on 2026-01-31 18:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("sample_notifications", "0004_alter_notification_verb"), + ("sample_notifications", "0004_replace_jsonfield_with_django_builtin"), + ] + + operations = []