Skip to content

Commit be3e1db

Browse files
authored
[deps] Add support for Django 5.x and update channels
- Dropped support for Python < 3.9 - Dropped support for Django < 4.2 Closes #340
1 parent b7dff78 commit be3e1db

19 files changed

+170
-79
lines changed

.github/workflows/build.yml

+19-3
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,29 @@ jobs:
2626
fail-fast: false
2727
matrix:
2828
python-version:
29-
- "3.8"
3029
- "3.9"
3130
- "3.10"
31+
- "3.11"
32+
- "3.12"
33+
- "3.13"
3234
django-version:
33-
- django~=3.2.0
34-
- django~=4.1.0
3535
- django~=4.2.0
36+
- django~=5.0.0
37+
- django~=5.1.0
38+
- django~=5.2.0
39+
exclude:
40+
# Django 5.0+ requires Python >=3.10
41+
- python-version: "3.9"
42+
django-version: django~=5.0.0
43+
- python-version: "3.9"
44+
django-version: django~=5.1.0
45+
- python-version: "3.9"
46+
django-version: django~=5.2.0
47+
# Python 3.13 supported only in Django >=5.1.3
48+
- python-version: "3.13"
49+
django-version: django~=4.2.0
50+
- python-version: "3.13"
51+
django-version: django~=5.0.0
3652

3753
steps:
3854
- uses: actions/checkout@v4

openwisp_notifications/base/models.py

+52-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import logging
22
from contextlib import contextmanager
33

4+
import django
45
from django.conf import settings
56
from django.contrib.contenttypes.fields import GenericForeignKey
67
from django.contrib.contenttypes.models import ContentType
78
from django.contrib.sites.models import Site
89
from django.core.cache import cache
10+
from django.core.exceptions import ObjectDoesNotExist
911
from django.db import models
1012
from django.db.models.constraints import UniqueConstraint
1113
from django.template.loader import render_to_string
@@ -22,6 +24,7 @@
2224
from openwisp_notifications.exceptions import NotificationRenderException
2325
from openwisp_notifications.types import (
2426
NOTIFICATION_CHOICES,
27+
get_notification_choices,
2528
get_notification_configuration,
2629
)
2730
from openwisp_notifications.utils import _get_absolute_url, _get_object_link
@@ -53,6 +56,15 @@ def notification_render_attributes(obj, **attrs):
5356
for target_attr, source_attr in defaults.items():
5457
setattr(obj, target_attr, getattr(obj, source_attr))
5558

59+
# In Django 5.1+, GenericForeignKey fields defined in parent models can no longer
60+
# be overridden in child models (https://code.djangoproject.com/ticket/36295).
61+
# To avoid multiple database queries, we explicitly set these attributes here
62+
# using our cached _related_object method instead of relying on the default
63+
# GenericForeignKey accessor which would bypass our caching mechanism.
64+
setattr(obj, 'actor', obj._related_object('actor'))
65+
setattr(obj, 'action_object', obj._related_object('action_object'))
66+
setattr(obj, 'target', obj._related_object('target'))
67+
5668
yield obj
5769

5870
for attr in defaults.keys():
@@ -61,7 +73,21 @@ def notification_render_attributes(obj, **attrs):
6173

6274
class AbstractNotification(UUIDModel, BaseNotification):
6375
CACHE_KEY_PREFIX = 'ow-notifications-'
64-
type = models.CharField(max_length=30, null=True, choices=NOTIFICATION_CHOICES)
76+
type = models.CharField(
77+
max_length=30,
78+
null=True,
79+
# TODO: Remove when dropping support for Django 4.2
80+
choices=(
81+
NOTIFICATION_CHOICES
82+
if django.VERSION < (5, 0)
83+
# In Django 5.0+, choices are normalized at model definition,
84+
# creating a static list of tuples that doesn't update when notification
85+
# types are dynamically registered or unregistered. Using a callable
86+
# ensures we always get the current choices from the registry.
87+
else get_notification_choices
88+
),
89+
verbose_name=_('Notification Type'),
90+
)
6591
_actor = BaseNotification.actor
6692
_action_object = BaseNotification.action_object
6793
_target = BaseNotification.target
@@ -117,7 +143,7 @@ def _get_related_object_url(self, field):
117143
return url_callable(self, field=field, absolute_url=True)
118144
except ImportError:
119145
return url
120-
return _get_object_link(self, field=field, absolute_url=True)
146+
return _get_object_link(obj=self._related_object(field), absolute_url=True)
121147

122148
@property
123149
def actor_url(self):
@@ -200,7 +226,19 @@ def _related_object(self, field):
200226
cache_key = self._cache_key(obj_content_type_id, obj_id)
201227
obj = cache.get(cache_key)
202228
if not obj:
203-
obj = getattr(self, f'_{field}')
229+
try:
230+
obj = getattr(self, f'_{field}')
231+
except AttributeError:
232+
# Django 5.1+ no longer respects overridden GenericForeignKey fields in model definitions.
233+
# Using `_actor = BaseNotification.actor` doesn't work as expected.
234+
# We must manually fetch the related object using content type and object ID.
235+
# See: https://code.djangoproject.com/ticket/36295
236+
try:
237+
obj = ContentType.objects.get_for_id(
238+
obj_content_type_id
239+
).get_object_for_this_type(pk=obj_id)
240+
except ObjectDoesNotExist:
241+
obj = None
204242
cache.set(
205243
cache_key,
206244
obj,
@@ -246,8 +284,17 @@ class AbstractNotificationSetting(UUIDModel):
246284
type = models.CharField(
247285
max_length=30,
248286
null=True,
249-
choices=NOTIFICATION_CHOICES,
250-
verbose_name='Notification Type',
287+
# TODO: Remove when dropping support for Django 4.2
288+
choices=(
289+
NOTIFICATION_CHOICES
290+
if django.VERSION < (5, 0)
291+
# In Django 5.0+, choices are normalized at model definition,
292+
# creating a static list of tuples that doesn't update when notification
293+
# types are dynamically registered or unregistered. Using a callable
294+
# ensures we always get the current choices from the registry.
295+
else get_notification_choices
296+
),
297+
verbose_name=_('Notification Type'),
251298
)
252299
organization = models.ForeignKey(
253300
get_model_name('openwisp_users', 'Organization'),

openwisp_notifications/migrations/0003_notification_notification_type.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import django
12
from django.db import migrations, models
23

3-
from openwisp_notifications.types import NOTIFICATION_CHOICES
4+
from openwisp_notifications.types import NOTIFICATION_CHOICES, get_notification_choices
45

56

67
class Migration(migrations.Migration):
@@ -13,9 +14,12 @@ class Migration(migrations.Migration):
1314
model_name='notification',
1415
name='type',
1516
field=models.CharField(
16-
choices=NOTIFICATION_CHOICES,
17+
choices=NOTIFICATION_CHOICES
18+
if django.VERSION < (5, 0)
19+
else get_notification_choices,
1720
max_length=30,
1821
null=True,
22+
verbose_name="Notification Type",
1923
),
2024
),
2125
]

openwisp_notifications/migrations/0004_notificationsetting.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
import uuid
44

5+
import django
56
import django.db.models.deletion
67
import swapper
78
from django.conf import settings
89
from django.contrib.auth.management import create_permissions
910
from django.db import migrations, models
1011

1112
from openwisp_notifications.migrations import get_swapped_model
12-
from openwisp_notifications.types import NOTIFICATION_CHOICES
13+
from openwisp_notifications.types import NOTIFICATION_CHOICES, get_notification_choices
1314

1415

1516
def create_notification_setting_groups_permissions(apps, schema_editor):
@@ -89,7 +90,9 @@ class Migration(migrations.Migration):
8990
(
9091
'type',
9192
models.CharField(
92-
choices=NOTIFICATION_CHOICES,
93+
choices=NOTIFICATION_CHOICES
94+
if django.VERSION < (5, 0)
95+
else get_notification_choices,
9396
max_length=30,
9497
null=True,
9598
verbose_name='Notification Type',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 5.2 on 2025-04-02 19:22
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("openwisp_notifications", "0007_notificationsetting_deleted"),
9+
]
10+
11+
operations = [
12+
migrations.RenameIndex(
13+
model_name="notification",
14+
new_name="openwisp_no_recipie_b7c4ef_idx",
15+
old_fields=("recipient", "unread"),
16+
),
17+
]

openwisp_notifications/tests/test_helpers.py

+12-20
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
from django.http import HttpRequest
77
from django.utils import timezone
88

9+
from openwisp_notifications.base.models import NOTIFICATION_CHOICES
910
from openwisp_notifications.swapper import load_model
1011
from openwisp_notifications.tasks import ns_register_unregister_notification_type
11-
from openwisp_notifications.types import NOTIFICATION_CHOICES, NOTIFICATION_TYPES
12+
from openwisp_notifications.types import NOTIFICATION_TYPES
1213
from openwisp_notifications.types import (
1314
register_notification_type as base_register_notification_type,
1415
)
@@ -41,6 +42,15 @@ def register_notification_type(type_name, type_config, models=[]):
4142
ns_register_unregister_notification_type.delay(
4243
notification_type=type_name, delete_unregistered=False
4344
)
45+
# Update choices for model fields directly
46+
# Django loads field choices during model initialization, but our mocked
47+
# NOTIFICATION_CHOICES don't automatically update field choices.
48+
# We need to explicitly update the field choices to ensure the models
49+
# use our test environment's notification types.
50+
from openwisp_notifications.types import NOTIFICATION_CHOICES
51+
52+
Notification._meta.get_field('type').choices = NOTIFICATION_CHOICES
53+
NotificationSetting._meta.get_field('type').choices = NOTIFICATION_CHOICES
4454

4555

4656
def unregister_notification_type(type_name):
@@ -66,24 +76,6 @@ def wrapper(*args, **kwargs):
6676
NOTIFICATION_CHOICES=deepcopy(NOTIFICATION_CHOICES),
6777
NOTIFICATION_TYPES=deepcopy(NOTIFICATION_TYPES),
6878
):
69-
from openwisp_notifications import types
70-
71-
# Here we mock the choices for model fields directly. This is
72-
# because the choices for model fields are defined during the
73-
# model loading phase, and any changes to the mocked
74-
# NOTIFICATION_CHOICES don't affect the model field choices.
75-
# So, we mock the choices directly here to ensure that any
76-
# changes to the mocked NOTIFICATION_CHOICES reflect in the
77-
# model field choices.
78-
with patch.object(
79-
Notification._meta.get_field('type'),
80-
'choices',
81-
types.NOTIFICATION_CHOICES,
82-
), patch.object(
83-
NotificationSetting._meta.get_field('type'),
84-
'choices',
85-
types.NOTIFICATION_CHOICES,
86-
):
87-
return func(*args, **kwargs)
79+
return func(*args, **kwargs)
8880

8981
return wrapper

openwisp_notifications/tests/test_notifications.py

+9-9
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ def test_notification_type_message_template(self):
397397
'level': 'info',
398398
'verb': 'message template verb',
399399
'verbose_name': 'Message Template Type',
400-
'email_subject': '[{site.name}] Messsage Template Subject',
400+
'email_subject': '[{site.name}] Message Template Subject',
401401
}
402402

403403
with self.subTest('Register type with non existent message template'):
@@ -414,7 +414,7 @@ def test_notification_type_message_template(self):
414414
self._create_notification()
415415
n = notification_queryset.first()
416416
self.assertEqual(n.type, 'message_template')
417-
self.assertEqual(n.email_subject, '[example.com] Messsage Template Subject')
417+
self.assertEqual(n.email_subject, '[example.com] Message Template Subject')
418418

419419
with self.subTest('Links in notification message'):
420420
url = _get_absolute_url(
@@ -633,16 +633,16 @@ def test_notification_invalid_message_attribute(self, mocked_task):
633633
def test_related_objects_database_query(self):
634634
operator = self._get_operator()
635635
self.notification_options.update(
636-
{'action_object': operator, 'target': operator}
636+
{'action_object': operator, 'target': operator, 'type': 'default'}
637637
)
638-
self._create_notification()
638+
n = self._create_notification().pop()[1][0]
639639
with self.assertNumQueries(1):
640-
# 1 query since all related objects are cached
641-
# when rendering the notification
640+
# Accessing email_message should access all related objects
641+
# (actor, action_object, target) but only execute a single
642+
# query since these objects are cached when rendering
643+
# the notification, rather than executing separate queries for each one.
642644
n = notification_queryset.first()
643-
self.assertEqual(n.actor, self.admin)
644-
self.assertEqual(n.action_object, operator)
645-
self.assertEqual(n.target, operator)
645+
n.message
646646

647647
@patch.object(app_settings, 'CACHE_TIMEOUT', 0)
648648
def test_notification_cache_timeout(self):

openwisp_notifications/tests/test_selenium.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def test_notification_relative_link(self):
4141
notification_elem = self.find_element(By.CLASS_NAME, 'ow-notification-elem')
4242
data_location_value = notification_elem.get_attribute('data-location')
4343
self.assertEqual(
44-
data_location_value, _get_object_link(notification, 'target', False)
44+
data_location_value, _get_object_link(notification.target, False)
4545
)
4646

4747
def test_notification_dialog(self):

openwisp_notifications/tests/test_websockets.py

-13
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import sys
21
import uuid
32
from datetime import timedelta
43
from unittest.mock import patch
@@ -215,10 +214,6 @@ async def test_short_term_notification_storm_prevention(
215214
async def test_long_term_notification_storm_prevention(
216215
self, admin_user, admin_client
217216
):
218-
if sys.version_info[:2] == (3, 7):
219-
NotificationConsumer._backoff_increment = 60
220-
NotificationConsumer._max_allowed_backoff = 5
221-
222217
datetime_now = now()
223218
freezer = freeze_time(datetime_now).start()
224219
await bulk_create_notification(admin_user, count=30)
@@ -232,12 +227,8 @@ async def test_long_term_notification_storm_prevention(
232227
# _initial_backoff is mocked since the time is frozen in this test
233228
# which was leading to initialization case getting skipped.
234229
with patch.object(NotificationConsumer, '_initial_backoff', 0):
235-
if sys.version_info[:2] == (3, 7):
236-
NotificationConsumer._initial_backoff = 0
237230
await create_notification(admin_user)
238231
response = await communicator.receive_json_from()
239-
if sys.version_info[:2] == (3, 7):
240-
NotificationConsumer._initial_backoff = 1
241232

242233
# These notifications will increment the backoff time beyond allowed limit.
243234
for _ in range(2):
@@ -263,7 +254,3 @@ async def test_long_term_notification_storm_prevention(
263254
assert response['reload_widget'] is True
264255

265256
await communicator.disconnect()
266-
267-
if sys.version_info[:1] == (3, 7):
268-
NotificationConsumer._backoff_increment = 1
269-
NotificationConsumer._max_allowed_backoff = 15

openwisp_notifications/types.py

+9
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,12 @@ def _unregister_notification_choice(notification_type):
111111
NOTIFICATION_CHOICES.pop(index)
112112
return
113113
raise ImproperlyConfigured(f'No such Notification Choice {notification_type}')
114+
115+
116+
def get_notification_choices():
117+
"""
118+
Returns the list of notification choices, which may
119+
be dyanmically changed at runtime by other openwisp
120+
modules which register new notification types.
121+
"""
122+
return NOTIFICATION_CHOICES

openwisp_notifications/utils.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@
33
from django.urls import NoReverseMatch, reverse
44

55

6-
def _get_object_link(obj, field, absolute_url=False, *args, **kwargs):
7-
related_obj = getattr(obj, field)
6+
def _get_object_link(obj, absolute_url=False, *args, **kwargs):
87
try:
98
url = reverse(
10-
f'admin:{related_obj._meta.app_label}_{related_obj._meta.model_name}_change',
11-
args=[related_obj.id],
9+
f'admin:{obj._meta.app_label}_{obj._meta.model_name}_change',
10+
args=[obj.id],
1211
)
1312
if absolute_url:
1413
url = _get_absolute_url(url)

requirements-test.txt

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
openwisp-utils[qa,selenium] @ https://github.com/openwisp/openwisp-utils/tarball/1.2
1+
openwisp-utils[qa,selenium,channels,channels-test] @ https://github.com/openwisp/openwisp-utils/tarball/1.2
22
django-cors-headers~=4.4.0
33
django-redis~=5.4.0
4-
channels_redis~=4.2.1
5-
pytest-asyncio~=0.24.0
6-
pytest-django~=4.10.0
74
freezegun~=1.5.1

0 commit comments

Comments
 (0)