Skip to content

Commit 2031330

Browse files
committed
[deps] Added support for Django >=5.1,<5.3 and Python >=3.12, 3.14 #340
- Dropped support for Python < 3.9 - Dropped support for Django < 4.2 Closes #340
1 parent 3678d8d commit 2031330

File tree

11 files changed

+102
-60
lines changed

11 files changed

+102
-60
lines changed

Diff for: .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

Diff for: openwisp_notifications/base/models.py

+24-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.contrib.contenttypes.models import ContentType
77
from django.contrib.sites.models import Site
88
from django.core.cache import cache
9+
from django.core.exceptions import ObjectDoesNotExist
910
from django.db import models
1011
from django.db.models.constraints import UniqueConstraint
1112
from django.template.loader import render_to_string
@@ -53,6 +54,15 @@ def notification_render_attributes(obj, **attrs):
5354
for target_attr, source_attr in defaults.items():
5455
setattr(obj, target_attr, getattr(obj, source_attr))
5556

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

5868
for attr in defaults.keys():
@@ -117,7 +127,7 @@ def _get_related_object_url(self, field):
117127
return url_callable(self, field=field, absolute_url=True)
118128
except ImportError:
119129
return url
120-
return _get_object_link(self, field=field, absolute_url=True)
130+
return _get_object_link(obj=self._related_object(field), absolute_url=True)
121131

122132
@property
123133
def actor_url(self):
@@ -200,7 +210,19 @@ def _related_object(self, field):
200210
cache_key = self._cache_key(obj_content_type_id, obj_id)
201211
obj = cache.get(cache_key)
202212
if not obj:
203-
obj = getattr(self, f'_{field}')
213+
try:
214+
obj = getattr(self, f'_{field}')
215+
except AttributeError:
216+
# Django 5.1+ no longer respects overridden GenericForeignKey fields in model definitions.
217+
# Using `_actor = BaseNotification.actor` doesn't work as expected.
218+
# We must manually fetch the related object using content type and object ID.
219+
# See: https://code.djangoproject.com/ticket/36295
220+
try:
221+
obj = ContentType.objects.get_for_id(
222+
obj_content_type_id
223+
).get_object_for_this_type(pk=obj_id)
224+
except ObjectDoesNotExist:
225+
obj = None
204226
cache.set(
205227
cache_key,
206228
obj,
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+
]

Diff for: 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

Diff for: 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):

Diff for: 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):

Diff for: 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

Diff for: 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)

Diff for: requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
django-notifications-hq~=1.8.3
1+
django-notifications-hq @ https://github.com/openwisp/django-notifications/tarball/django-5.x
22
openwisp-users @ https://github.com/openwisp/openwisp-users/tarball/1.2
33
openwisp-utils[rest,celery,channels] @ https://github.com/openwisp/openwisp-utils/tarball/1.2
44
markdown~=3.7.0

Diff for: setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,6 @@ def get_install_requires():
6464
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
6565
'Operating System :: OS Independent',
6666
'Framework :: Django',
67-
'Programming Language :: Python :: 3.8',
67+
'Programming Language :: Python :: 3',
6868
],
6969
)

Diff for: tests/openwisp2/sample_notifications/migrations/0001_initial.py

+15-6
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
from django.conf import settings
1010
from django.db import migrations, models
1111

12-
from openwisp_notifications.types import NOTIFICATION_CHOICES
13-
1412

1513
class Migration(migrations.Migration):
1614
initial = True
@@ -156,7 +154,10 @@ class Migration(migrations.Migration):
156154
(
157155
'type',
158156
models.CharField(
159-
choices=NOTIFICATION_CHOICES,
157+
choices=[
158+
("default", "Default Type"),
159+
("generic_message", "Generic Message Type"),
160+
],
160161
max_length=30,
161162
null=True,
162163
),
@@ -166,11 +167,16 @@ class Migration(migrations.Migration):
166167
options={
167168
'ordering': ('-timestamp',),
168169
'abstract': False,
169-
'index_together': {('recipient', 'unread')},
170170
'verbose_name': 'Notification',
171171
'verbose_name_plural': 'Notifications',
172172
},
173173
),
174+
migrations.AddIndex(
175+
model_name="notification",
176+
index=models.Index(
177+
fields=["recipient", "unread"], name="sample_noti_recipie_e2f36b_idx"
178+
),
179+
),
174180
migrations.CreateModel(
175181
name='NotificationSetting',
176182
fields=[
@@ -186,10 +192,13 @@ class Migration(migrations.Migration):
186192
(
187193
'type',
188194
models.CharField(
189-
choices=NOTIFICATION_CHOICES,
195+
choices=[
196+
("default", "Default Type"),
197+
("generic_message", "Generic Message Type"),
198+
],
190199
max_length=30,
191200
null=True,
192-
verbose_name='Notification Type',
201+
verbose_name="Notification Type",
193202
),
194203
),
195204
(

0 commit comments

Comments
 (0)