Skip to content

Commit b456ee2

Browse files
authored
Merge branch 'master' into issues/1049-send-generic-message
2 parents 56c9386 + 828dfb3 commit b456ee2

File tree

10 files changed

+219
-26
lines changed

10 files changed

+219
-26
lines changed

.github/workflows/ci.yml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ jobs:
2121
fail-fast: false
2222
matrix:
2323
python-version:
24-
- "3.9"
2524
- "3.10"
2625
- "3.11"
2726
- "3.12"
@@ -31,11 +30,6 @@ jobs:
3130
- django~=5.1.0
3231
- django~=5.2.0
3332
exclude:
34-
# Django 5.1+ requires Python >=3.10
35-
- python-version: "3.9"
36-
django-version: django~=5.1.0
37-
- python-version: "3.9"
38-
django-version: django~=5.2.0
3933
# Python 3.13 supported only in Django >=5.1.3
4034
- python-version: "3.13"
4135
django-version: django~=4.2.0

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# NOTE: This Docker image is for development purposes only.
22

3-
FROM python:3.9-slim-buster
3+
FROM python:3.12-slim-bookworm
44

55
RUN apt update && \
66
apt install --yes zlib1g-dev libjpeg-dev gdal-bin libproj-dev \

docs/developer/installation.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Developer Installation Instructions
1010
Dependencies
1111
------------
1212

13-
- Python >= 3.9
13+
- Python >= 3.10
1414
- OpenSSL
1515

1616
Installing for Development

openwisp_controller/config/admin.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,10 +352,13 @@ def get_temp_model_instance(self, **options):
352352
config_model = self.Meta.model
353353
instance = config_model(**options)
354354
device_model = config_model.device.field.related_model
355-
org = Organization.objects.get(pk=self.data["organization"])
355+
if not (org_id := self.data.get("organization")):
356+
# We cannot validate the templates without an organization.
357+
return
358+
org = Organization.objects.get(pk=org_id)
356359
instance.device = device_model(
357-
name=self.data["name"],
358-
mac_address=self.data["mac_address"],
360+
name=self.data.get("name", ""),
361+
mac_address=self.data.get("mac_address", ""),
359362
organization=org,
360363
)
361364
return instance
@@ -369,6 +372,12 @@ def clean_templates(self):
369372
# when adding self.instance is empty, we need to create a
370373
# temporary instance that we'll use just for validation
371374
config = self.get_temp_model_instance(**data)
375+
if not config:
376+
# The request does not contain vaild data to create a temporary
377+
# Device instance. Thus, we cannot validate the templates.
378+
# The Device validation will be handled by DeviceAdmin.
379+
# Therefore, we don't need to raise any error here.
380+
return
372381
else:
373382
config = self.instance
374383
if config.backend and templates:

openwisp_controller/config/base/device.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,25 @@ def get_temp_config_instance(self, **options):
435435
config.device = self
436436
return config
437437

438+
def should_skip_push_update(self):
439+
"""
440+
Check if push update should be skipped for this device.
441+
442+
Clears the skip flag after reading it.
443+
"""
444+
result = getattr(self, "_skip_push_update", False)
445+
if result:
446+
delattr(self, "_skip_push_update")
447+
return result
448+
449+
def skip_push_update_on_save(self):
450+
"""
451+
Marks the device to skip push update on the next save.
452+
This is useful when updating device properties during re-registration
453+
to avoid triggering unnecessary push operations.
454+
"""
455+
self._skip_push_update = True
456+
438457
def can_be_updated(self):
439458
"""
440459
returns True if the device can and should be updated

openwisp_controller/config/controller/views.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ class DeviceRegisterView(UpdateLastIpMixin, CsrfExtemptMixin, View):
289289
model = Device
290290
org_config_settings_model = OrganizationConfigSettings
291291

292-
UPDATABLE_FIELDS = ["os", "model", "system"]
292+
UPDATABLE_FIELDS = ["name", "os", "model", "system"]
293293

294294
def init_object(self, **kwargs):
295295
"""
@@ -402,10 +402,13 @@ def post(self, request, *args, **kwargs):
402402
new = False
403403
try:
404404
device = self.model.objects.get(key=key)
405-
# update hw info
405+
# update device info
406406
for attr in self.UPDATABLE_FIELDS:
407407
if attr in request.POST:
408-
setattr(device, attr, request.POST.get(attr))
408+
if attr == "name":
409+
self._update_device_name(request, device)
410+
else:
411+
setattr(device, attr, request.POST.get(attr))
409412
config = device.config
410413
# if get queryset fails, instantiate a new Device and Config
411414
except self.model.DoesNotExist:
@@ -457,6 +460,27 @@ def post(self, request, *args, **kwargs):
457460
s.format(**attributes), content_type="text/plain", status=201
458461
)
459462

463+
def _update_device_name(self, request, device):
464+
"""
465+
Updates the device name during re-registration if the name in the
466+
payload is not equal to the device's MAC address.
467+
468+
Agents send the MAC address as hostname when the hostname is "OpenWrt"
469+
or if default_hostname is set to *. This method prevents overwriting
470+
user-configured hostnames with default/generic MAC address values.
471+
"""
472+
name = request.POST.get("name")
473+
mac_address = device.mac_address
474+
normalized_mac = (
475+
mac_address.replace(":", "").replace("-", "").lower()
476+
if mac_address
477+
else None
478+
)
479+
normalized_name = name.replace(":", "").replace("-", "").lower()
480+
if normalized_name != normalized_mac:
481+
device.name = name
482+
device.skip_push_update_on_save()
483+
460484

461485
class GetVpnView(SingleObjectMixin, View):
462486
"""

openwisp_controller/config/tests/test_admin.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2279,6 +2279,36 @@ def test_templates_fetch_queries_10(self):
22792279
config = self._create_config(organization=self._get_org())
22802280
self._verify_template_queries(config, 10)
22812281

2282+
def test_empty_device_form_with_config_inline(self):
2283+
org = self._get_org()
2284+
template = self._create_template(organization=org)
2285+
path = reverse(f"admin:{self.app_label}_device_add")
2286+
# Submit form without required device fields but with config inline
2287+
# This reproduces the scenario where user clicks "Add another Configuration"
2288+
# and submits without filling device details
2289+
params = {
2290+
"config-0-backend": "netjsonconfig.OpenWrt",
2291+
"config-0-templates": str(template.pk),
2292+
"config-0-config": json.dumps({}),
2293+
"config-0-context": "",
2294+
"config-TOTAL_FORMS": 1,
2295+
"config-INITIAL_FORMS": 0,
2296+
"config-MIN_NUM_FORMS": 0,
2297+
"config-MAX_NUM_FORMS": 1,
2298+
"deviceconnection_set-TOTAL_FORMS": 0,
2299+
"deviceconnection_set-INITIAL_FORMS": 0,
2300+
"deviceconnection_set-MIN_NUM_FORMS": 0,
2301+
"deviceconnection_set-MAX_NUM_FORMS": 1000,
2302+
"command_set-TOTAL_FORMS": 0,
2303+
"command_set-INITIAL_FORMS": 0,
2304+
"command_set-MIN_NUM_FORMS": 0,
2305+
"command_set-MAX_NUM_FORMS": 1000,
2306+
}
2307+
response = self.client.post(path, params)
2308+
self.assertEqual(response.status_code, 200)
2309+
self.assertContains(response, "errorlist")
2310+
self.assertEqual(Device.objects.count(), 0)
2311+
22822312

22832313
class TestTransactionAdmin(
22842314
CreateConfigTemplateMixin,

openwisp_controller/config/tests/test_controller.py

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.core.exceptions import ValidationError
66
from django.http.response import Http404
77
from django.test import TestCase
8-
from django.urls import reverse
8+
from django.urls import reverse, reverse_lazy
99
from swapper import load_model
1010

1111
from openwisp_utils.tests import capture_any_output, catch_signal
@@ -38,13 +38,8 @@
3838
Organization = load_model("openwisp_users", "Organization")
3939

4040

41-
class TestController(CreateConfigTemplateMixin, TestVpnX509Mixin, TestCase):
42-
"""
43-
tests for config.controller
44-
"""
45-
46-
def setUp(self):
47-
self.register_url = reverse("controller:device_register")
41+
class TestRegistrationMixin:
42+
register_url = reverse_lazy("controller:device_register")
4843

4944
def _create_org(self, shared_secret=TEST_ORG_SHARED_SECRET, **kwargs):
5045
org = super()._create_org(**kwargs)
@@ -53,6 +48,27 @@ def _create_org(self, shared_secret=TEST_ORG_SHARED_SECRET, **kwargs):
5348
)
5449
return org
5550

51+
def _get_reregistration_payload(self, device, **kwargs):
52+
data = {
53+
"secret": str(device.organization.config_settings.shared_secret),
54+
"key": TEST_CONSISTENT_KEY,
55+
"mac_address": device.mac_address,
56+
"backend": "netjsonconfig.OpenWrt",
57+
"model": "TP-Link TL-WDR4300 v2",
58+
"os": "OpenWrt 18.06-SNAPSHOT r7312-e60be11330",
59+
"system": "Atheros AR9344 rev 3",
60+
}
61+
data.update(**kwargs)
62+
return data
63+
64+
65+
class TestController(
66+
TestRegistrationMixin, CreateConfigTemplateMixin, TestVpnX509Mixin, TestCase
67+
):
68+
"""
69+
tests for config.controller
70+
"""
71+
5672
def _check_header(self, response):
5773
self.assertEqual(response["X-Openwisp-Controller"], "true")
5874

@@ -820,6 +836,83 @@ def test_device_registration_update_hw_info_no_config(self):
820836
self.assertEqual(d.system, params["system"])
821837
self.assertEqual(d.model, params["model"])
822838

839+
@patch.object(Device, "skip_push_update_on_save")
840+
def test_device_registration_update_hostname(self, mocked_method):
841+
"""
842+
Test that hostname is updated when the name in payload
843+
is not the MAC address stored in OpenWISP
844+
"""
845+
device = self._create_device_config(
846+
device_opts={
847+
"name": "old-hostname",
848+
"mac_address": TEST_MACADDR,
849+
"key": TEST_CONSISTENT_KEY,
850+
}
851+
)
852+
params = self._get_reregistration_payload(
853+
device=device,
854+
name="new-custom-hostname",
855+
)
856+
self.assertNotEqual(device.name, params["name"])
857+
response = self.client.post(self.register_url, params)
858+
self.assertEqual(response.status_code, 201)
859+
device.refresh_from_db()
860+
self.assertEqual(device.name, "new-custom-hostname")
861+
mocked_method.assert_called_once()
862+
863+
@patch.object(Device, "skip_push_update_on_save")
864+
def test_device_registration_hostname_not_updated_when_mac_address(
865+
self, mocked_method
866+
):
867+
"""
868+
Test that hostname is not updated when the name in payload
869+
equals the MAC address (agents send MAC address as hostname
870+
when hostname is OpenWrt or if default_hostname is set to *)
871+
"""
872+
device = self._create_device_config(
873+
device_opts={
874+
"name": "meaningful-hostname",
875+
"key": TEST_CONSISTENT_KEY,
876+
}
877+
)
878+
params = self._get_reregistration_payload(
879+
device=device,
880+
name=TEST_MACADDR,
881+
)
882+
response = self.client.post(self.register_url, params)
883+
self.assertEqual(response.status_code, 201)
884+
device.refresh_from_db()
885+
self.assertEqual(device.name, "meaningful-hostname")
886+
mocked_method.assert_not_called()
887+
888+
@patch.object(Device, "skip_push_update_on_save")
889+
def test_device_registration_hostname_comparison_case_insensitive(
890+
self, mocked_method
891+
):
892+
"""
893+
Test that MAC address comparison is case-insensitive and works
894+
with different formats (colons, dashes, no separators)
895+
"""
896+
mac_address = "00:11:22:33:aa:BB"
897+
name = mac_address.replace(":", "-")
898+
device = self._create_device_config(
899+
device_opts={
900+
"mac_address": mac_address,
901+
"name": name,
902+
"key": TEST_CONSISTENT_KEY,
903+
}
904+
)
905+
params = self._get_reregistration_payload(
906+
device=device,
907+
name="00-11-22-33-aa-bb",
908+
)
909+
response = self.client.post(self.register_url, params)
910+
self.assertEqual(response.status_code, 201)
911+
device.refresh_from_db()
912+
# Hostname should not be changed
913+
self.assertEqual(device.name, name)
914+
mocked_method.assert_not_called()
915+
823916
def test_device_report_status_running(self):
824917
"""
825918
maintained for backward compatibility

openwisp_controller/connection/apps.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def ready(self):
6464

6565
@classmethod
6666
def config_modified_receiver(cls, **kwargs):
67-
transaction.on_commit(lambda: cls._launch_update_config(kwargs["device"].pk))
67+
transaction.on_commit(lambda: cls._launch_update_config(kwargs["device"]))
6868

6969
@classmethod
7070
def command_save_receiver(cls, sender, created, instance, **kwargs):
@@ -81,14 +81,17 @@ def command_save_receiver(cls, sender, created, instance, **kwargs):
8181
)
8282

8383
@classmethod
84-
def _launch_update_config(cls, device_id):
84+
def _launch_update_config(cls, device):
8585
"""
8686
Calls the background task update_config only if
8787
no other tasks are running for the same device
8888
"""
8989
from .tasks import update_config
9090

91-
update_config.delay(device_id)
91+
# Check if push update should be skipped
92+
if device.should_skip_push_update():
93+
return
94+
update_config.delay(str(device.pk))
9295

9396
@classmethod
9497
def is_working_changed_receiver(

openwisp_controller/connection/tests/test_tasks.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
from unittest import mock
55

66
from celery.exceptions import SoftTimeLimitExceeded
7-
from django.test import TestCase
7+
from django.test import TestCase, TransactionTestCase
88
from swapper import load_model
99

10+
from ...config.tests.test_controller import TestRegistrationMixin
1011
from .. import tasks
1112
from .utils import CreateConnectionsMixin
1213

1314
Command = load_model("connection", "Command")
15+
OrganizationConfigSettings = load_model("config", "OrganizationConfigSettings")
1416

1517

1618
class TestTasks(CreateConnectionsMixin, TestCase):
@@ -85,3 +87,22 @@ def test_launch_command_exception(self, *args):
8587
command.refresh_from_db()
8688
self.assertEqual(command.status, "failed")
8789
self.assertEqual(command.output, "Internal system error: test error\n")
90+
91+
92+
class TestTransactionTasks(
93+
TestRegistrationMixin, CreateConnectionsMixin, TransactionTestCase
94+
):
95+
@mock.patch.object(tasks.update_config, "delay")
96+
def test_update_config_hostname_changed_on_reregister(self, mocked_update_config):
97+
device = self._create_device_config()
98+
self._create_device_connection(device=device)
99+
# Trigger re-registration with new hostname
100+
response = self.client.post(
101+
self.register_url,
102+
self._get_reregistration_payload(
103+
device,
104+
name="new-hostname",
105+
),
106+
)
107+
self.assertEqual(response.status_code, 201)
108+
mocked_update_config.assert_not_called()

0 commit comments

Comments
 (0)