diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4078958b..2cc6b4ffc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,19 +13,30 @@ on: jobs: build: name: Python==${{ matrix.python-version }} | ${{ matrix.django-version }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: python-version: - - "3.8" - "3.9" - "3.10" + - "3.11" + - "3.12" + - "3.13" django-version: - - django~=3.2.0 - - django~=4.1.0 - django~=4.2.0 + - django~=5.1.0 + - django~=5.2.0 + exclude: + # Django 5.1+ requires Python >=3.10 + - python-version: "3.9" + django-version: django~=5.1.0 + - python-version: "3.9" + django-version: django~=5.2.0 + # Python 3.13 supported only in Django >=5.1.3 + - python-version: "3.13" + django-version: django~=4.2.0 steps: - uses: actions/checkout@v4 diff --git a/docker-compose.yml b/docker-compose.yml index 145be7c59..bceefa452 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,5 @@ # NOTE: This Docker image is for development purposes only. -version: "3" - services: controller: image: openwisp/controller-development:latest @@ -21,7 +19,7 @@ services: entrypoint: redis-server --appendonly yes postgres: - image: postgis/postgis:13-3.3-alpine + image: postgis/postgis:17-3.5-alpine environment: POSTGRES_PASSWORD: openwisp2 POSTGRES_USER: openwisp2 diff --git a/docs/developer/installation.rst b/docs/developer/installation.rst index ef55485ac..1ea810a19 100644 --- a/docs/developer/installation.rst +++ b/docs/developer/installation.rst @@ -10,7 +10,7 @@ Developer Installation Instructions Dependencies ------------ -- Python >= 3.8 +- Python >= 3.9 - OpenSSL Installing for Development diff --git a/openwisp_controller/config/base/vpn.py b/openwisp_controller/config/base/vpn.py index c4d6aed27..e2c09dcde 100644 --- a/openwisp_controller/config/base/vpn.py +++ b/openwisp_controller/config/base/vpn.py @@ -6,7 +6,6 @@ from copy import deepcopy from subprocess import CalledProcessError, TimeoutExpired -import django import shortuuid from cache_memoize import cache_memoize from django.core.cache import cache @@ -856,13 +855,9 @@ def register_auto_ip_stopper(cls, func): cls._auto_ip_stopper_funcs.append(func) def _get_unique_checks(self, exclude=None, include_meta_constraints=False): - if django.VERSION < (4, 1): - # TODO: Remove when dropping support for Django 3.2 - unique_checks, date_checks = super()._get_unique_checks(exclude) - else: - unique_checks, date_checks = super()._get_unique_checks( - exclude, include_meta_constraints - ) + unique_checks, date_checks = super()._get_unique_checks( + exclude, include_meta_constraints + ) if not self.vpn._vxlan_vni: # If VNI is not specified in VXLAN tunnel configuration, diff --git a/openwisp_controller/config/tests/test_controller.py b/openwisp_controller/config/tests/test_controller.py index 06d9f0a2d..3bbb97675 100644 --- a/openwisp_controller/config/tests/test_controller.py +++ b/openwisp_controller/config/tests/test_controller.py @@ -1346,7 +1346,7 @@ def test_ip_fields_not_duplicated(self): self.assertIsNone(c1.device.management_ip) self.assertEqual(c2.device.management_ip, '192.168.1.99') # other organization is not affected - self.assertEquals(c3.device.last_ip, '127.0.0.1') + self.assertEqual(c3.device.last_ip, '127.0.0.1') self.assertEqual(c3.device.management_ip, '192.168.1.99') with self.subTest('test interaction with DeviceChecksumView caching'): diff --git a/openwisp_controller/config/tests/test_template.py b/openwisp_controller/config/tests/test_template.py index 53540c6ea..02b93e3f4 100644 --- a/openwisp_controller/config/tests/test_template.py +++ b/openwisp_controller/config/tests/test_template.py @@ -12,7 +12,6 @@ from openwisp_utils.tests import catch_signal -from ...tests.utils import TransactionTestMixin from .. import settings as app_settings from ..signals import config_modified, config_status_changed from ..tasks import logger as task_logger @@ -356,7 +355,10 @@ def test_context_regression(self): template_qs = Template.objects.filter(type='vpn') self.assertEqual(template_qs.count(), 1) t = template_qs.first() - self.assertDictContainsSubset(_original_context, t.get_context()) + context = t.get_context() + # check all items from original context exist in template context + for key, value in _original_context.items(): + self.assertEqual(context.get(key), value) self.assertEqual(app_settings.CONTEXT, _original_context) with self.subTest( @@ -517,7 +519,6 @@ def test_regression_preventing_from_fixing_invalid_conf(self): class TestTemplateTransaction( - TransactionTestMixin, CreateConfigTemplateMixin, TestVpnX509Mixin, TransactionTestCase, @@ -554,7 +555,7 @@ def test_config_status_modified_after_change(self): with catch_signal(config_status_changed) as handler: t.config['interfaces'][0]['name'] = 'eth2' t.full_clean() - with self.assertNumQueries(9): + with self.assertNumQueries(10): t.save() c.refresh_from_db() handler.assert_not_called() diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index 70c1e57ed..0a145d045 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -1,6 +1,7 @@ import collections import logging +import django import jsonschema from django.core.exceptions import ValidationError from django.db import models, transaction @@ -24,6 +25,7 @@ ORGANIZATION_COMMAND_SCHEMA, ORGANIZATION_ENABLED_COMMANDS, get_command_callable, + get_command_choices, get_command_schema, ) from ..exceptions import NoWorkingDeviceConnectionError @@ -408,7 +410,18 @@ class AbstractCommand(TimeStampedEditableModel): status = models.CharField( max_length=12, choices=STATUS_CHOICES, default=STATUS_CHOICES[0][0] ) - type = models.CharField(max_length=16, choices=COMMAND_CHOICES) + type = models.CharField( + max_length=16, + choices=( + COMMAND_CHOICES + if django.VERSION < (5, 0) + # In Django 5.0+, choices are normalized at model definition, + # creating a static list of tuples that doesn't update when command + # are dynamically registered or unregistered. Using a callable + # ensures we always get the current choices from the registry. + else get_command_choices + ), + ) input = JSONField( blank=True, null=True, diff --git a/openwisp_controller/connection/commands.py b/openwisp_controller/connection/commands.py index 725e82847..cfd2e7a41 100644 --- a/openwisp_controller/connection/commands.py +++ b/openwisp_controller/connection/commands.py @@ -155,3 +155,10 @@ def _unregister_command_choice(command): ORGANIZATION_COMMAND_SCHEMA[org_id] = OrderedDict() for command in commands: ORGANIZATION_COMMAND_SCHEMA[org_id][command] = COMMANDS[command]['schema'] + + +def get_command_choices(): + """ + Returns the command choices. + """ + return COMMAND_CHOICES diff --git a/openwisp_controller/connection/migrations/0007_command.py b/openwisp_controller/connection/migrations/0007_command.py index 4a5e0e424..3a8b1d8d3 100644 --- a/openwisp_controller/connection/migrations/0007_command.py +++ b/openwisp_controller/connection/migrations/0007_command.py @@ -3,6 +3,7 @@ import collections import uuid +import django import django.db.migrations.operations.special import django.db.models.deletion import django.utils.timezone @@ -11,7 +12,7 @@ import swapper from django.db import migrations, models -from ..commands import COMMAND_CHOICES +from ..commands import COMMAND_CHOICES, get_command_choices from . import assign_command_permissions_to_groups @@ -65,7 +66,9 @@ class Migration(migrations.Migration): ( 'type', models.CharField( - choices=COMMAND_CHOICES, + choices=COMMAND_CHOICES + if django.VERSION < (5, 0) + else get_command_choices, max_length=16, ), ), diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py index fbf0778a9..1653f6cff 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -11,7 +11,6 @@ from openwisp_utils.tests import capture_any_output, catch_signal -from ...tests.utils import TransactionTestMixin from .. import settings as app_settings from ..commands import ( COMMANDS, @@ -888,7 +887,7 @@ def test_command_multiple_connections(self, connect_mocked): self.assertIn(command.connection, [dc1, dc2]) -class TestModelsTransaction(TransactionTestMixin, BaseTestModels, TransactionTestCase): +class TestModelsTransaction(BaseTestModels, TransactionTestCase): def _prepare_conf_object(self, organization=None): if not organization: organization = self._create_org(name='org1') @@ -1121,12 +1120,12 @@ def test_chunk_size(self): organization=org, name='device3', mac_address='33:33:33:33:33:33' ) ) - with self.assertNumQueries(31): + with self.assertNumQueries(32): credential = self._create_credentials(auto_add=True, organization=org) self.assertEqual(credential.deviceconnection_set.count(), 3) with mock.patch.object(Credentials, 'chunk_size', 2): - with self.assertNumQueries(33): + with self.assertNumQueries(35): credential = self._create_credentials( name='Mocked Credential', auto_add=True, organization=org ) diff --git a/openwisp_controller/connection/tests/test_ssh.py b/openwisp_controller/connection/tests/test_ssh.py index ae9c23e66..5819ce0d6 100644 --- a/openwisp_controller/connection/tests/test_ssh.py +++ b/openwisp_controller/connection/tests/test_ssh.py @@ -1,5 +1,4 @@ import os -import sys from unittest import mock from django.conf import settings @@ -54,9 +53,8 @@ def test_connection_connect_auth_failure(self, mocked_ssh_close): self.assertEqual(mocked_connect.call_count, 2) self.assertFalse(dc.is_working) self.assertEqual(mocked_ssh_close.call_count, 2) - if sys.version_info[0:2] > (3, 7): - self.assertNotIn('disabled_algorithms', mocked_connect.mock_calls[0].kwargs) - self.assertIn('disabled_algorithms', mocked_connect.mock_calls[1].kwargs) + self.assertNotIn('disabled_algorithms', mocked_connect.mock_calls[0].kwargs) + self.assertIn('disabled_algorithms', mocked_connect.mock_calls[1].kwargs) @mock.patch.object(ssh_logger, 'info') @mock.patch.object(ssh_logger, 'debug') diff --git a/openwisp_controller/connection/utils.py b/openwisp_controller/connection/utils.py index 450b2c11d..d657348a9 100644 --- a/openwisp_controller/connection/utils.py +++ b/openwisp_controller/connection/utils.py @@ -2,5 +2,5 @@ def get_connection_working_notification_target_url(obj, field, absolute_url=True): - url = _get_object_link(obj, field, absolute_url) + url = _get_object_link(obj._related_object(field), absolute_url) return f'{url}#deviceconnection_set-group' diff --git a/openwisp_controller/geo/tests/test_admin_inline.py b/openwisp_controller/geo/tests/test_admin_inline.py index 1531fa7fb..f0a8af4bc 100644 --- a/openwisp_controller/geo/tests/test_admin_inline.py +++ b/openwisp_controller/geo/tests/test_admin_inline.py @@ -78,3 +78,4 @@ def test_add_mobile(self): del TestConfigAdmin +del BaseTestAdminInline diff --git a/openwisp_controller/geo/tests/test_api.py b/openwisp_controller/geo/tests/test_api.py index 9a9ba6c67..6e65cc8a7 100644 --- a/openwisp_controller/geo/tests/test_api.py +++ b/openwisp_controller/geo/tests/test_api.py @@ -577,7 +577,7 @@ def test_change_location_type_to_outdoor_api(self): self._create_floorplan(location=l1) path = reverse('geo_api:detail_location', args=[l1.pk]) data = {'type': 'outdoor'} - with self.assertNumQueries(8): + with self.assertNumQueries(9): response = self.client.patch(path, data, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['floorplan'], []) diff --git a/openwisp_controller/tests/utils.py b/openwisp_controller/tests/utils.py index 7efb02053..5f2caa890 100644 --- a/openwisp_controller/tests/utils.py +++ b/openwisp_controller/tests/utils.py @@ -1,8 +1,4 @@ -import django from django.contrib.auth import get_user_model -from django.db import connections -from django.db.utils import DEFAULT_DB_ALIAS -from django.test.testcases import _AssertNumQueriesContext from django.urls import reverse from openwisp_users.tests.utils import TestMultitenantAdminMixin @@ -20,32 +16,3 @@ def _test_changelist_recover_deleted(self, app_label, model_label): def _login(self, username='admin', password='tester'): self.client.force_login(user_model.objects.get(username=username)) - - -class _ManagementTransactionNumQueriesContext(_AssertNumQueriesContext): - def __exit__(self, exc_type, exc_value, traceback): - """ - Django 4.2 introduced support for logging transaction - management queries (BEGIN, COMMIT, and ROLLBACK). - This method increases the number of expected database - queries if COMMIT/ROLLBACK queries are found when - using Django 4.2 - """ - if exc_type is not None: - return - for query in self.captured_queries: - if django.VERSION > (4, 2) and 'COMMIT' in query['sql']: - self.num += 1 - super().__exit__(exc_type, exc_value, traceback) - - -class TransactionTestMixin(object): - def assertNumQueries(self, num, func=None, *args, using=DEFAULT_DB_ALIAS, **kwargs): - conn = connections[using] - - context = _ManagementTransactionNumQueriesContext(self, num, conn) - if func is None: - return context - - with context: - func(*args, **kwargs) diff --git a/requirements-test.txt b/requirements-test.txt index 610cf6791..1f8581b14 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,8 +1,5 @@ -pytest-django~=4.9.0 -pytest-asyncio~=0.24.0 pytest-cov~=5.0.0 -openwisp-utils[qa,selenium] @ https://github.com/openwisp/openwisp-utils/tarball/1.2 -channels_redis~=4.2.1 +openwisp-utils[qa,selenium,channels-test] @ https://github.com/openwisp/openwisp-utils/tarball/1.2 django_redis~=5.4.0 mock-ssh-server~=0.9.1 responses~=0.25.6 diff --git a/requirements.txt b/requirements.txt index 052827759..d188b7a9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ django-sortedm2m~=4.0.0 django-reversion~=5.1.0 -django-taggit~=4.0.0 +django-taggit~=6.0.0 netjsonconfig @ https://github.com/openwisp/netjsonconfig/tarball/1.2 django-x509 @ https://github.com/openwisp/django-x509/tarball/1.3 django-loci @ https://github.com/openwisp/django-loci/tarball/1.2 django-flat-json-widget~=0.3.1 openwisp-users @ https://github.com/openwisp/openwisp-users/tarball/1.2 -openwisp-utils[celery] @ https://github.com/openwisp/openwisp-utils/tarball/1.2 +openwisp-utils[celery,channels] @ https://github.com/openwisp/openwisp-utils/tarball/1.2 openwisp-notifications @ https://github.com/openwisp/openwisp-notifications/tarball/1.2 openwisp-ipam @ https://github.com/openwisp/openwisp-ipam/tarball/1.2 djangorestframework-gis @ https://github.com/openwisp/django-rest-framework-gis/tarball/1.2 diff --git a/tests/openwisp2/asgi.py b/tests/openwisp2/asgi.py index 09b85e80d..818bcb83e 100644 --- a/tests/openwisp2/asgi.py +++ b/tests/openwisp2/asgi.py @@ -1,6 +1,7 @@ from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from channels.security.websocket import AllowedHostsOriginValidator +from django.core.asgi import get_asgi_application from openwisp_controller.routing import get_routes @@ -8,6 +9,7 @@ { 'websocket': AllowedHostsOriginValidator( AuthMiddlewareStack(URLRouter(get_routes())) - ) + ), + 'http': get_asgi_application(), } ) diff --git a/tests/openwisp2/sample_connection/migrations/0001_initial.py b/tests/openwisp2/sample_connection/migrations/0001_initial.py index 769afa4fc..17205a3da 100644 --- a/tests/openwisp2/sample_connection/migrations/0001_initial.py +++ b/tests/openwisp2/sample_connection/migrations/0001_initial.py @@ -3,6 +3,7 @@ import collections import uuid +import django import django.db.models.deletion import django.utils.timezone import jsonfield.fields @@ -14,7 +15,7 @@ import openwisp_controller.connection.base.models import openwisp_users.mixins from openwisp_controller.connection import settings as connection_settings -from openwisp_controller.connection.commands import COMMAND_CHOICES +from openwisp_controller.connection.commands import COMMAND_CHOICES, get_command_choices class Migration(migrations.Migration): @@ -245,7 +246,9 @@ class Migration(migrations.Migration): ( 'type', models.CharField( - choices=COMMAND_CHOICES, + choices=COMMAND_CHOICES + if django.VERSION < (5, 0) + else get_command_choices, max_length=16, ), ), diff --git a/tests/openwisp2/sample_users/migrations/0001_initial.py b/tests/openwisp2/sample_users/migrations/0001_initial.py index d3e08ab9d..4fa85512f 100644 --- a/tests/openwisp2/sample_users/migrations/0001_initial.py +++ b/tests/openwisp2/sample_users/migrations/0001_initial.py @@ -204,7 +204,12 @@ class Migration(migrations.Migration): 'verbose_name': 'user', 'verbose_name_plural': 'users', 'abstract': False, - 'index_together': {('id', 'email')}, + 'indexes': [ + models.Index( + fields=['id', 'email'], + name='user_id_email_idx', + ) + ], }, managers=[('objects', openwisp_users.base.models.UserManager())], ), diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index f626695e9..faa4180e0 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -22,6 +22,7 @@ SECRET_KEY = 'fn)t*+$)ugeyip6-#txyy$5wf2ervc0d2n#h)qb)y5@ly$t*@w' INSTALLED_APPS = [ + 'daphne', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions',