Skip to content

Commit 8be9541

Browse files
pandafynemesifier
authored andcommitted
[deps] Added support for Django >=5.1,<5.3 and Python >=3.12, 3.14 #645
- Upgraded django-nested-admin~=4.1.0 - Dropped support for Python < 3.9 - Dropped support for Django < 4.2 Closes #645
1 parent bd80781 commit 8be9541

File tree

8 files changed

+162
-47
lines changed

8 files changed

+162
-47
lines changed

.github/workflows/ci.yml

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ on:
1414
jobs:
1515
build:
1616
name: Python==${{ matrix.python-version }} | ${{ matrix.django-version }}
17-
# Update this only when support for Django 3.2 is removed
18-
runs-on: ubuntu-20.04
17+
runs-on: ubuntu-24.04
1918

2019
services:
2120
redis:
@@ -27,13 +26,24 @@ jobs:
2726
fail-fast: false
2827
matrix:
2928
python-version:
30-
- "3.8"
3129
- "3.9"
3230
- "3.10"
31+
- "3.11"
32+
- "3.12"
33+
- "3.13"
3334
django-version:
34-
- django~=3.2.0
35-
- django~=4.1.0
3635
- django~=4.2.0
36+
- django~=5.1.0
37+
- django~=5.2.0
38+
exclude:
39+
# Django 5.1+ requires Python >=3.10
40+
- python-version: "3.9"
41+
django-version: django~=5.1.0
42+
- python-version: "3.9"
43+
django-version: django~=5.2.0
44+
# Python 3.13 supported only in Django >=5.1.3
45+
- python-version: "3.13"
46+
django-version: django~=4.2.0
3747

3848
steps:
3949
- uses: actions/checkout@v4
@@ -77,7 +87,7 @@ jobs:
7787
pip install -U pip wheel setuptools
7888
pip install -r requirements-test.txt
7989
pip install -U -I -e .
80-
pip uninstall -y django
90+
pip uninstall -y Django
8191
pip install -U ${{ matrix.django-version }}
8292
sudo npm install -g prettier
8393

openwisp_monitoring/device/tests/test_admin.py

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django.contrib.contenttypes.forms import generic_inlineformset_factory
77
from django.core.cache import cache
88
from django.db import connection
9-
from django.test import TestCase
9+
from django.test import TestCase, override_settings
1010
from django.urls import reverse
1111
from django.utils.timezone import datetime, now, timedelta
1212
from freezegun import freeze_time
@@ -89,6 +89,28 @@ def _login_admin(self):
8989
u = User.objects.create_superuser('admin', 'admin', 'test@test.com')
9090
self.client.force_login(u)
9191

92+
def _get_inline_admin_heading(self, heading):
93+
"""
94+
TODO: Remove this when dropping support for Django 4.2
95+
96+
Django 5.1 introduced a new way to render inline headings with IDs and classes.
97+
For versions before 5.1, we use the old-style heading format except for "Alert Settings"
98+
which is already handled differently by django-nested-admin==4.1.0.
99+
This method helps us generate the appropriate HTML heading format
100+
based on the Django version being used.
101+
"""
102+
if django.VERSION < (5, 1) and heading != 'Alert Settings':
103+
return f'<h2>{heading}</h2>'
104+
heading_map = {
105+
'Checks': f'{Check._meta.app_label}-check-content_type-object_id-heading',
106+
'Alert Settings': f'{Metric._meta.app_label}-metric-content_type-object_id-heading',
107+
'WiFi Sessions': 'wifisession_set-heading',
108+
'Configuration': 'config-heading',
109+
'Map': 'devicelocation-heading',
110+
'Credentials': 'deviceconnection_set-heading',
111+
}
112+
return f'<h2 id="{heading_map[heading]}" class="inline-heading">\n\n{heading}\n\n</h2>'
113+
92114
def test_device_admin(self):
93115
dd = self.create_test_data()
94116
check = Check.objects.create(
@@ -101,7 +123,9 @@ def test_device_admin(self):
101123
response = self.client.get(url)
102124
self.assertContains(response, '<h2>Status</h2>')
103125
self.assertContains(response, '<h2>Charts</h2>')
104-
self.assertContains(response, '<h2>Checks</h2>')
126+
self.assertContains(
127+
response, self._get_inline_admin_heading('Checks'), html=True
128+
)
105129
self.assertContains(response, 'Storage')
106130
self.assertContains(response, 'CPU')
107131
self.assertContains(response, 'RAM status')
@@ -315,9 +339,11 @@ def test_device_add_view(self):
315339
url = reverse('admin:config_device_add')
316340
r = self.client.get(url)
317341
self.assertNotContains(r, 'AlertSettings')
318-
self.assertContains(r, '<h2>Configuration</h2>')
319-
self.assertContains(r, '<h2>Map</h2>')
320-
self.assertContains(r, '<h2>Credentials</h2>')
342+
self.assertContains(
343+
r, self._get_inline_admin_heading('Configuration'), html=True
344+
)
345+
self.assertContains(r, self._get_inline_admin_heading('Map'), html=True)
346+
self.assertContains(r, self._get_inline_admin_heading('Credentials'), html=True)
321347

322348
def test_device_disabled_organization_admin(self):
323349
self.create_test_data()
@@ -335,8 +361,12 @@ def test_device_disabled_organization_admin(self):
335361
response = self.client.get(url)
336362
self.assertContains(response, '<h2>Status</h2>')
337363
self.assertContains(response, '<h2>Charts</h2>')
338-
self.assertNotContains(response, '<h2>Checks</h2>')
339-
self.assertNotContains(response, '<h2>AlertSettings</h2>')
364+
self.assertNotContains(
365+
response, self._get_inline_admin_heading('Checks'), html=True
366+
)
367+
self.assertNotContains(
368+
response, self._get_inline_admin_heading('Alert Settings'), html=True
369+
)
340370

341371
def test_remove_invalid_interface(self):
342372
d = self._create_device(organization=self._create_org())
@@ -535,22 +565,34 @@ def test_wifisession_inline(self):
535565

536566
with self.subTest('Test inline absent when no WiFiSession is present'):
537567
response = self.client.get(path)
538-
self.assertNotContains(response, '<h2>WiFi Sessions</h2>')
568+
self.assertNotContains(
569+
response,
570+
self._get_inline_admin_heading('WiFi Sessions'),
571+
html=True,
572+
)
539573
self.assertNotContains(response, 'monitoring-wifisession-changelist-url')
540574

541575
wifi_session = self._create_wifi_session(device=device)
542576

543577
with self.subTest('Test inline present when WiFiSession is open'):
544578
response = self.client.get(path)
545-
self.assertContains(response, '<h2>WiFi Sessions</h2>')
579+
self.assertContains(
580+
response,
581+
self._get_inline_admin_heading('WiFi Sessions'),
582+
html=True,
583+
)
546584
self.assertContains(response, 'monitoring-wifisession-changelist-url')
547585

548586
wifi_session.stop_time = now()
549587
wifi_session.save()
550588

551589
with self.subTest('Test inline absent when WiFiSession is closed'):
552590
response = self.client.get(path)
553-
self.assertNotContains(response, '<h2>WiFi Sessions</h2>')
591+
self.assertNotContains(
592+
response,
593+
self._get_inline_admin_heading('WiFi Sessions'),
594+
html=True,
595+
)
554596
self.assertNotContains(response, 'monitoring-wifisession-changelist-url')
555597

556598
def test_check_alertsetting_inline(self):
@@ -584,13 +626,17 @@ def _add_user_permissions(user, permission_query, expected_perm_count):
584626
self.assertEqual(user.user_permissions.count(), expected_perm_count)
585627

586628
def _assert_check_inline_in_response(response):
587-
self.assertContains(response, '<h2>Checks</h2>', html=True)
629+
self.assertContains(
630+
response, self._get_inline_admin_heading('Checks'), html=True
631+
)
588632
self.assertContains(response, 'check-content_type-object_id-0-is_active')
589633
self.assertContains(response, 'check-content_type-object_id-0-check_type')
590634
self.assertContains(response, 'check-content_type-object_id-0-DELETE')
591635

592636
def _assert_alertsettings_inline_in_response(response):
593-
self.assertContains(response, '<h2>Alert Settings</h2>', html=True)
637+
self.assertContains(
638+
response, self._get_inline_admin_heading('Alert Settings'), html=True
639+
)
594640
self.assertContains(response, 'form-row field-name')
595641
self.assertContains(
596642
response,
@@ -620,8 +666,12 @@ def _assert_alertsettings_inline_in_response(response):
620666
_add_device_permissions(test_user)
621667
response = self.client.get(url)
622668
self.assertEqual(response.status_code, 200)
623-
self.assertNotContains(response, '<h2>Checks</h2>', html=True)
624-
self.assertNotContains(response, '<h2>Alert Settings</h2>', html=True)
669+
self.assertNotContains(
670+
response, self._get_inline_admin_heading('Checks'), html=True
671+
)
672+
self.assertNotContains(
673+
response, self._get_inline_admin_heading('Alert Settings'), html=True
674+
)
625675

626676
with self.subTest('Test check & alert settings with model permissions'):
627677
_add_device_permissions(test_user)
@@ -659,10 +709,14 @@ def _assert_alertsettings_inline_in_response(response):
659709
)
660710
response = self.client.get(url)
661711
self.assertEqual(response.status_code, 200)
662-
self.assertContains(response, '<h2>Checks</h2>', html=True)
712+
self.assertContains(
713+
response, self._get_inline_admin_heading('Checks'), html=True
714+
)
663715
self.assertContains(response, 'form-row field-check_type')
664716
self.assertContains(response, 'form-row field-is_active')
665-
self.assertContains(response, '<h2>Alert Settings</h2>', html=True)
717+
self.assertContains(
718+
response, self._get_inline_admin_heading('Alert Settings'), html=True
719+
)
666720
self.assertContains(response, 'form-row field-is_healthy djn-form-row-last')
667721
self.assertContains(
668722
response,
@@ -1064,44 +1118,43 @@ def test_wifi_client_he_vht_ht_unknown(self):
10641118
"""
10651119
<div class="form-row field-he">
10661120
<div>
1067-
{start_div}
1121+
<div class="flex-container">
10681122
<label>WiFi 6 (802.11ax):</label>
10691123
<div class="readonly">
10701124
<img src="/static/admin/img/icon-unknown.svg">
10711125
</div>
1072-
{end_div}
1126+
</div>
10731127
</div>
10741128
</div>
10751129
<div class="form-row field-vht">
10761130
<div>
1077-
{start_div}<label>WiFi 5 (802.11ac):</label>
1131+
<div class="flex-container">
1132+
<label>WiFi 5 (802.11ac):</label>
10781133
<div class="readonly">
10791134
<img src="/static/admin/img/icon-unknown.svg">
10801135
</div>
1081-
{end_div}
1136+
</div>
10821137
</div>
10831138
</div>
10841139
<div class="form-row field-ht">
10851140
<div>
1086-
{start_div}<label>WiFi 4 (802.11n):</label>
1141+
<div class="flex-container">
1142+
<label>WiFi 4 (802.11n):</label>
10871143
<div class="readonly">
10881144
<img src="/static/admin/img/icon-unknown.svg">
10891145
</div>
1090-
{end_div}
1146+
</div>
10911147
</div>
10921148
</div>
1093-
""".format(
1094-
# TODO: Remove this when dropping support for Django 3.2 and 4.0
1095-
start_div=(
1096-
'<div class="flex-container">'
1097-
if django.VERSION >= (4, 2)
1098-
else ''
1099-
),
1100-
end_div='</div>' if django.VERSION >= (4, 2) else '',
1101-
),
1149+
""",
11021150
html=True,
11031151
)
11041152

1153+
# TODO: Remove override_setting when dropping support for Django 4.2
1154+
# The DATETIME_FORMAT for en-gb locale changed for Django 5.1+,
1155+
# thus we override the project setting here to have consistent
1156+
# result with tests.
1157+
@override_settings(LANGUAGE_CODE='en')
11051158
def test_wifi_session_stop_time_formatting(self):
11061159
start_time = datetime.strptime('2023-8-24 17:08:00', '%Y-%m-%d %H:%M:%S')
11071160
stop_time = datetime.strptime('2023-8-24 19:46:00', '%Y-%m-%d %H:%M:%S')

openwisp_monitoring/monitoring/base/models.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from copy import deepcopy
55
from datetime import date, datetime, timedelta
66

7+
import django
78
from cache_memoize import cache_memoize
89
from dateutil.parser import parse as parse_date
910
from django.conf import settings
@@ -31,7 +32,9 @@
3132
DEFAULT_COLORS,
3233
METRIC_CONFIGURATION_CHOICES,
3334
get_chart_configuration,
35+
get_chart_configuration_choices,
3436
get_metric_configuration,
37+
get_metric_configuration_choices,
3538
)
3639
from ..exceptions import InvalidChartConfigException, InvalidMetricConfigException
3740
from ..signals import pre_metric_write, threshold_crossed
@@ -69,7 +72,18 @@ class AbstractMetric(TimeStampedEditableModel):
6972
help_text=_('leave blank to determine automatically'),
7073
)
7174
configuration = models.CharField(
72-
max_length=16, null=True, choices=METRIC_CONFIGURATION_CHOICES
75+
max_length=16,
76+
null=True,
77+
choices=(
78+
METRIC_CONFIGURATION_CHOICES
79+
if django.VERSION < (5, 0)
80+
# TODO: Remove when dropping support for Django 4.2
81+
# In Django 5.0+, choices are normalized at model definition,
82+
# creating a static list of tuples that doesn't update when metrics
83+
# are dynamically registered or unregistered. Using a callable
84+
# ensures we always get the current choices from the registry.
85+
else get_metric_configuration_choices
86+
),
7387
)
7488
content_type = models.ForeignKey(
7589
ContentType, on_delete=models.CASCADE, null=True, blank=True
@@ -489,7 +503,18 @@ class AbstractChart(TimeStampedEditableModel):
489503
get_model_name('monitoring', 'Metric'), on_delete=models.CASCADE
490504
)
491505
configuration = models.CharField(
492-
max_length=16, null=True, choices=CHART_CONFIGURATION_CHOICES
506+
max_length=16,
507+
null=True,
508+
choices=(
509+
CHART_CONFIGURATION_CHOICES
510+
if django.VERSION < (5, 0)
511+
# TODO: Remove when dropping support for Django 4.2
512+
# In Django 5.0+, choices are normalized at model definition,
513+
# creating a static list of tuples that doesn't update when charts
514+
# are dynamically registered or unregistered. Using a callable
515+
# ensures we always get the current choices from the registry.
516+
else get_chart_configuration_choices
517+
),
493518
)
494519
GROUP_MAP = {'1d': '10m', '3d': '20m', '7d': '1h', '30d': '24h', '365d': '7d'}
495520
DEFAULT_TIME = DEFAULT_CHART_TIME

openwisp_monitoring/monitoring/migrations/0001_squashed_0023_alert_settings_tolerance_remove_default.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import uuid
44

5+
import django
56
import django.core.validators
67
import django.db.migrations.operations.special
78
import django.db.models.deletion
@@ -12,7 +13,12 @@
1213
from django.db import migrations, models
1314
from swapper import split
1415

15-
from ..configuration import CHART_CONFIGURATION_CHOICES, METRIC_CONFIGURATION_CHOICES
16+
from ..configuration import (
17+
CHART_CONFIGURATION_CHOICES,
18+
METRIC_CONFIGURATION_CHOICES,
19+
get_chart_configuration_choices,
20+
get_metric_configuration_choices,
21+
)
1622

1723

1824
class Migration(migrations.Migration):
@@ -59,7 +65,11 @@ class Migration(migrations.Migration):
5965
(
6066
'configuration',
6167
models.CharField(
62-
choices=METRIC_CONFIGURATION_CHOICES, max_length=16, null=True
68+
choices=METRIC_CONFIGURATION_CHOICES
69+
if django.VERSION < (5, 0)
70+
else get_metric_configuration_choices,
71+
max_length=16,
72+
null=True,
6373
),
6474
),
6575
(
@@ -128,7 +138,11 @@ class Migration(migrations.Migration):
128138
(
129139
'configuration',
130140
models.CharField(
131-
choices=CHART_CONFIGURATION_CHOICES, max_length=16, null=True
141+
choices=CHART_CONFIGURATION_CHOICES
142+
if django.VERSION < (5, 0)
143+
else get_chart_configuration_choices,
144+
max_length=16,
145+
null=True,
132146
),
133147
),
134148
(

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
openwisp-controller @ https://github.com/openwisp/openwisp-controller/tarball/1.2
22
influxdb~=5.3.2
3-
django-nested-admin~=4.0.2
3+
django-nested-admin~=4.1.0
44
python-dateutil>=2.7.0,<3.0.0

0 commit comments

Comments
 (0)