Skip to content

Commit d79d252

Browse files
committed
[change] Auto add default and required templates to existing devices #480
Closes #480
1 parent 4ea68be commit d79d252

File tree

6 files changed

+267
-11
lines changed

6 files changed

+267
-11
lines changed

docs/user/templates.rst

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Default Templates
7979
:alt: Templates enabled by default
8080

8181
When templates are flagged as **"Enabled by default"**, they will be
82-
automatically assigned to new devices.
82+
automatically assigned to new and existing devices.
8383

8484
This is a very powerful feature: **once default templates are correctly
8585
configured to implement the use case you need, you will only have to
@@ -95,11 +95,10 @@ default templates without the need of manual intervention from the network
9595
operators.
9696

9797
An organization specific template flagged as default will be automatically
98-
assigned to any new device which will be created in the same organization.
98+
assigned to all existing and new devices in the same organization.
9999

100100
A shared default template instead will be automatically assigned to all
101-
the new devices which will be created in the system, regardless of
102-
organization.
101+
existing and new devices in the system, regardless of organization.
103102

104103
.. _required_templates:
105104

openwisp_controller/config/base/template.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import logging
23
from collections import OrderedDict
34
from copy import copy
45

@@ -7,14 +8,19 @@
78
from django.utils.translation import gettext_lazy as _
89
from jsonfield import JSONField
910
from netjsonconfig.exceptions import ValidationError as NetjsonconfigValidationError
10-
from swapper import get_model_name
11+
from swapper import get_model_name, load_model
1112
from taggit.managers import TaggableManager
1213

1314
from ...base import ShareableOrgMixinUniqueName
1415
from ..settings import DEFAULT_AUTO_CERT
15-
from ..tasks import update_template_related_config_status
16+
from ..tasks import (
17+
auto_add_template_to_existing_config,
18+
update_template_related_config_status,
19+
)
1620
from .base import BaseConfig
1721

22+
logger = logging.getLogger(__name__)
23+
1824
TYPE_CHOICES = (("generic", _("Generic")), ("vpn", _("VPN-client")))
1925

2026

@@ -61,7 +67,7 @@ class AbstractTemplate(ShareableOrgMixinUniqueName, BaseConfig):
6167
default=False,
6268
db_index=True,
6369
help_text=_(
64-
"whether new configurations will have this template enabled by default"
70+
"whether all configurations will have this template enabled by default"
6571
),
6672
)
6773
required = models.BooleanField(
@@ -124,14 +130,31 @@ def pre_save_handler(cls, instance, *args, **kwargs):
124130
# the old configuration may become invalid, raising an exception.
125131
# Setting the checksum to None forces related configurations to update.
126132
current_checksum = None
127-
instance._update_related_config_status = instance.checksum != current_checksum
133+
instance._should_update_related_config_status = (
134+
instance.checksum != current_checksum
135+
)
136+
137+
# Check if template is becoming default or required
138+
if (instance.default and not current.default) or (
139+
instance.required and not current.required
140+
):
141+
instance._should_add_to_existing_configs = True
128142

129143
@classmethod
130144
def post_save_handler(cls, instance, created, *args, **kwargs):
131-
if not created and getattr(instance, "_update_related_config_status", False):
145+
if not created and getattr(
146+
instance, "_should_update_related_config_status", False
147+
):
132148
transaction.on_commit(
133149
lambda: update_template_related_config_status.delay(instance.pk)
134150
)
151+
# Auto-add template to existing configs if it's new or became default/required
152+
if getattr(instance, "_should_add_to_existing_configs", False) or (
153+
created and (instance.default or instance.required)
154+
):
155+
transaction.on_commit(
156+
lambda: auto_add_template_to_existing_config.delay(str(instance.pk))
157+
)
135158

136159
def _update_related_config_status(self):
137160
# use atomic to ensure any code bound to
@@ -153,6 +176,44 @@ def _update_related_config_status(self):
153176
if config.pk in changing_status:
154177
config._send_config_status_changed_signal()
155178

179+
def _auto_add_to_existing_configs(self):
180+
"""
181+
When a template is ``default`` or ``required``, adds the template
182+
to each relevant ``Config`` object
183+
"""
184+
Config = load_model("config", "Config")
185+
186+
# Only proceed if template is default or required
187+
if not (self.default or self.required):
188+
return
189+
190+
# use atomic to ensure any code bound to
191+
# be executed via transaction.on_commit
192+
# is executed after the whole block
193+
with transaction.atomic():
194+
# Exclude deactivating or deactivated configs
195+
configs = (
196+
Config.objects.select_related("device")
197+
.filter(
198+
backend=self.backend,
199+
)
200+
.exclude(
201+
models.Q(status__in=["deactivating", "deactivated"])
202+
| models.Q(templates__id=self.pk)
203+
)
204+
)
205+
if self.organization_id:
206+
configs = configs.filter(device__organization_id=self.organization_id)
207+
for config in configs.iterator():
208+
try:
209+
config.templates.add(self)
210+
except Exception as e:
211+
# Log error but continue with other configs
212+
logger.exception(
213+
f"Failed to add template {self.pk} to "
214+
f"config {config.pk}: {e}"
215+
)
216+
156217
def clean(self, *args, **kwargs):
157218
"""
158219
* validates org relationship of VPN if present

openwisp_controller/config/migrations/0001_squashed_0002_config_settings_uuid.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ class Migration(migrations.Migration):
294294
db_index=True,
295295
default=False,
296296
help_text=(
297-
"whether new configurations will have this "
297+
"whether all configurations will have this "
298298
"template enabled by default"
299299
),
300300
verbose_name="enabled by default",

openwisp_controller/config/tasks.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ def update_template_related_config_status(template_pk):
3737
)
3838

3939

40+
@shared_task(soft_time_limit=7200)
41+
def auto_add_template_to_existing_config(template_pk):
42+
Template = load_model("config", "Template")
43+
try:
44+
template = Template.objects.get(pk=template_pk)
45+
except ObjectDoesNotExist as e:
46+
logger.warning(
47+
f'auto_add_template_to_existing_config("{template_pk}") failed: {e}'
48+
)
49+
return
50+
try:
51+
template._auto_add_to_existing_configs()
52+
except SoftTimeLimitExceeded:
53+
logger.error(
54+
"soft time limit hit while executing "
55+
f"_auto_add_to_existing_configs for {template} "
56+
f"(ID: {template_pk})"
57+
)
58+
59+
4060
@shared_task(soft_time_limit=1200)
4161
def create_vpn_dh(vpn_pk):
4262
"""

openwisp_controller/config/tests/test_template.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from .. import settings as app_settings
1616
from ..signals import config_modified, config_status_changed
17+
from ..tasks import auto_add_template_to_existing_config
1718
from ..tasks import logger as task_logger
1819
from ..tasks import update_template_related_config_status
1920
from .utils import CreateConfigTemplateMixin, TestVpnX509Mixin
@@ -517,6 +518,31 @@ def test_regression_preventing_from_fixing_invalid_conf(self):
517518
template.full_clean()
518519
template.save()
519520

521+
def test_auto_add_to_existing_configs_error_handling(self):
522+
with self.subTest("Template is not default or required"):
523+
template = self._create_template(
524+
name="test-template", required=False, default=False
525+
)
526+
with mock.patch.object(transaction, "atomic") as mocked_atomic:
527+
template._auto_add_to_existing_configs()
528+
mocked_atomic.assert_not_called()
529+
530+
with self.subTest("config.templates.add(template) raises error"):
531+
config = self._create_config(device=self._create_device(name="test-device"))
532+
template.required = True
533+
template.full_clean()
534+
template.save()
535+
with mock.patch.object(
536+
Config.templates.related_manager_cls,
537+
"add",
538+
side_effect=ValueError("Incompatible template"),
539+
), mock.patch("logging.Logger.exception") as mocked_logger:
540+
template._auto_add_to_existing_configs()
541+
mocked_logger.assert_called_once_with(
542+
f"Failed to add template {template.pk} to config {config.pk}:"
543+
" Incompatible template"
544+
)
545+
520546

521547
class TestTemplateTransaction(
522548
CreateConfigTemplateMixin,
@@ -749,3 +775,153 @@ def test_task_timeout(self, mocked_update_related_config_status):
749775
template.save()
750776
mocked_error.assert_called_once()
751777
mocked_update_related_config_status.assert_called_once()
778+
779+
def _create_env_for_adding_template_to_existing_config(self):
780+
org1 = self._get_org()
781+
org1_config = self._create_config(organization=org1, status="applied")
782+
org2 = self._create_org(name="org2", slug="org2")
783+
org2_config = self._create_config(organization=org2, status="applied")
784+
org2_deactivating_config = self._create_config(
785+
status="deactivating",
786+
device=self._create_device(
787+
name="test-deactivating",
788+
mac_address="11:22:33:44:55:67",
789+
organization=org2,
790+
),
791+
)
792+
org2_deactivated_config = self._create_config(
793+
status="deactivated",
794+
device=self._create_device(
795+
name="test-deactivated",
796+
mac_address="11:22:33:44:55:68",
797+
organization=org2,
798+
),
799+
)
800+
return (
801+
org1_config,
802+
org2_config,
803+
org2_deactivating_config,
804+
org2_deactivated_config,
805+
org1,
806+
org2,
807+
)
808+
809+
def _assert_template_added_to_existing_config(
810+
self,
811+
template,
812+
org1_config,
813+
org2_config,
814+
org2_deactivating_config,
815+
org2_deactivated_config,
816+
):
817+
org1_config.refresh_from_db()
818+
self.assertEqual(org1_config.status, "modified")
819+
self.assertIn(template, org1_config.templates.all())
820+
org2_config.refresh_from_db()
821+
self.assertEqual(org2_config.status, "modified")
822+
self.assertIn(template, org2_config.templates.all())
823+
824+
org2_deactivating_config.refresh_from_db()
825+
self.assertEqual(org2_deactivating_config.status, "deactivating")
826+
self.assertEqual(org2_deactivating_config.templates.count(), 0)
827+
828+
org2_deactivated_config.refresh_from_db()
829+
self.assertEqual(org2_deactivated_config.status, "deactivated")
830+
self.assertEqual(org2_deactivated_config.templates.count(), 0)
831+
832+
def test_required_template_added_to_existing_config(self):
833+
(
834+
org1_config,
835+
org2_config,
836+
org2_deactivating_config,
837+
org2_deactivated_config,
838+
_,
839+
_,
840+
) = self._create_env_for_adding_template_to_existing_config()
841+
shared_template = self._create_template(
842+
name="shared-template", required=True, default=True
843+
)
844+
self._assert_template_added_to_existing_config(
845+
shared_template,
846+
org1_config,
847+
org2_config,
848+
org2_deactivating_config,
849+
org2_deactivated_config,
850+
)
851+
852+
def test_default_template_added_to_existing_config(self):
853+
(
854+
org1_config,
855+
org2_config,
856+
org2_deactivating_config,
857+
org2_deactivated_config,
858+
_,
859+
_,
860+
) = self._create_env_for_adding_template_to_existing_config()
861+
default_template = self._create_template(name="default-template", default=True)
862+
self._assert_template_added_to_existing_config(
863+
default_template,
864+
org1_config,
865+
org2_config,
866+
org2_deactivating_config,
867+
org2_deactivated_config,
868+
)
869+
870+
def test_update_existing_template_to_be_required(self):
871+
template = self._create_template(
872+
name="existing-template", required=False, default=False
873+
)
874+
config = self._create_config(organization=self._get_org())
875+
self.assertNotIn(template, config.templates.all())
876+
877+
template.required = True
878+
template.full_clean()
879+
template.save()
880+
881+
config.refresh_from_db()
882+
self.assertEqual(config.status, "modified")
883+
self.assertIn(template, config.templates.all())
884+
885+
def test_update_existing_template_to_be_default(self):
886+
template = self._create_template(
887+
name="existing-template", required=False, default=False
888+
)
889+
config = self._create_config(organization=self._get_org())
890+
self.assertNotIn(template, config.templates.all())
891+
892+
template.default = True
893+
template.full_clean()
894+
template.save()
895+
896+
config.refresh_from_db()
897+
self.assertEqual(config.status, "modified")
898+
self.assertIn(template, config.templates.all())
899+
900+
@mock.patch.object(auto_add_template_to_existing_config, "delay")
901+
def test_auto_add_template_to_existing_config_not_triggered(self, mocked_task):
902+
"""
903+
Ensure that auto_add_template_to_existing_config task is not triggered
904+
when a required/default template is updated.
905+
"""
906+
with self.subTest("Updating default template does not trigger task"):
907+
default_template = self._create_template(
908+
name="default-template", default=True
909+
)
910+
mocked_task.assert_called_once_with(str(default_template.pk))
911+
mocked_task.reset_mock()
912+
913+
default_template.config["interfaces"][0]["name"] = "eth1"
914+
default_template.full_clean()
915+
default_template.save()
916+
mocked_task.assert_not_called()
917+
918+
with self.subTest("Updating required template does not trigger task"):
919+
required_template = self._create_template(
920+
name="required-template", required=True
921+
)
922+
mocked_task.assert_called_once_with(str(required_template.pk))
923+
mocked_task.reset_mock()
924+
required_template.config["interfaces"][0]["name"] = "eth1"
925+
required_template.full_clean()
926+
required_template.save()
927+
mocked_task.assert_not_called()

tests/openwisp2/sample_config/migrations/0001_initial.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ class Migration(migrations.Migration):
527527
db_index=True,
528528
default=False,
529529
help_text=(
530-
"whether new configurations will have this "
530+
"whether all configurations will have this "
531531
"template enabled by default"
532532
),
533533
verbose_name="enabled by default",

0 commit comments

Comments
 (0)