Skip to content

Commit 8b8dda6

Browse files
authored
[feature] Added organization variables #780
Closes #780
1 parent 97b1e08 commit 8b8dda6

15 files changed

Lines changed: 283 additions & 33 deletions

File tree

README.rst

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,8 +537,9 @@ the order (high to low) of their precedence:
537537
1. `User defined device variables <#user-defined-device-variables>`_
538538
2. `Predefined device variables <#predefined-device-variables>`_
539539
3. `Group variables <#group-variables>`_
540-
4. `Global variables <#global-variables>`_
541-
5. `Template default values <#template-default-values>`_
540+
4. `Organization variables <#organization-variables>`_
541+
5. `Global variables <#global-variables>`_
542+
6. `Template default values <#template-default-values>`_
542543

543544
User defined device variables
544545
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -568,6 +569,18 @@ Variables can also be defined in `Device groups <#device-groups>`__.
568569
Refer the `Group configuration variables <group-configuration-variables>`_
569570
section for detailed information.
570571

572+
Organization variables
573+
~~~~~~~~~~~~~~~~~~~~~~
574+
575+
Variables can also be defined at the organization level.
576+
577+
You can set the *organization variables* from the organization change page
578+
``/admin/openwisp_users/organization/<organization-id>/change/``, under the
579+
**Configuration Management Settings**.
580+
581+
.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/organization-variables.png
582+
:alt: organization variables
583+
571584
Global variables
572585
~~~~~~~~~~~~~~~~
573586

openwisp_controller/config/admin.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@ def _get_preview_instance(self, request):
663663
c.device.mac_address = request.POST.get('mac_address')
664664
c.device.key = request.POST.get('key')
665665
c.device.group_id = request.POST.get('group') or None
666+
c.device.organization_id = request.POST.get('organization_id') or None
666667
if 'hardware_id' in request.POST:
667668
c.device.hardware_id = request.POST.get('hardware_id')
668669
return c
@@ -1087,7 +1088,8 @@ def has_delete_permission(self, request, obj):
10871088
if getattr(app_settings, 'REGISTRATION_ENABLED', True):
10881089

10891090
class ConfigSettingsForm(AlwaysHasChangedMixin, forms.ModelForm):
1090-
pass
1091+
class Meta:
1092+
widgets = {'context': FlatJsonWidget}
10911093

10921094
class ConfigSettingsInline(admin.StackedInline):
10931095
model = OrganizationConfigSettings

openwisp_controller/config/base/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ def get_cached_checksum(self):
150150
logger.debug(f'calculating checksum for config ID {self.pk}')
151151
return self.checksum
152152

153+
@classmethod
154+
def bulk_invalidate_get_cached_checksum(cls, query_params):
155+
for config in cls.objects.only('id').filter(**query_params).iterator():
156+
config.get_cached_checksum.invalidate(config)
157+
153158
@classmethod
154159
def get_template_model(cls):
155160
return cls.templates.rel.model
@@ -616,6 +621,10 @@ def get_context(self, system=False):
616621
('key', self.key),
617622
]
618623
)
624+
config_settings = self.device._get_organization__config_settings()
625+
if config_settings:
626+
# Add organization variables
627+
context.update(config_settings.get_context())
619628
if self.device._get_group():
620629
# Add device group variables
621630
context.update(self.device._get_group().get_context())

openwisp_controller/config/base/device.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ def _has_config(self):
130130
def _has_group(self):
131131
return hasattr(self, 'group')
132132

133+
def _has_organization__config_settings(self):
134+
return hasattr(self, 'organization') and hasattr(
135+
self.organization, 'config_settings'
136+
)
137+
133138
def _get_config_attr(self, attr):
134139
"""
135140
gets property or calls method of related config object
@@ -151,6 +156,13 @@ def _get_group(self):
151156
return self.group
152157
return self.get_group_model()(device=self)
153158

159+
def _get_organization__config_settings(self):
160+
if self._has_organization__config_settings():
161+
return self.organization.config_settings
162+
return load_model('config', 'OrganizationConfigSettings')(
163+
organization=self.organization if hasattr(self, 'organization') else None
164+
)
165+
154166
def get_context(self):
155167
config = self._get_config()
156168
return config.get_context()
@@ -357,6 +369,14 @@ def get_default_templates(self):
357369
def get_config_model(cls):
358370
return cls._meta.get_field('config').related_model
359371

372+
@classmethod
373+
def get_group_model(cls):
374+
return cls._meta.get_field('group').related_model
375+
376+
@classmethod
377+
def get_config_settings_model(cls):
378+
return
379+
360380
def get_temp_config_instance(self, **options):
361381
config = self.get_config_model()(**options)
362382
config.device = self

openwisp_controller/config/base/multitenancy.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import collections
2+
from copy import deepcopy
3+
14
import swapper
25
from django.db import models
36
from django.utils.translation import gettext_lazy as _
7+
from jsonfield import JSONField
48

59
from openwisp_utils.base import KeyField, UUIDModel
610

711
from ..exceptions import OrganizationDeviceLimitExceeded
12+
from ..tasks import bulk_invalidate_config_get_cached_checksum
813

914

1015
class AbstractOrganizationConfigSettings(UUIDModel):
@@ -27,6 +32,16 @@ class AbstractOrganizationConfigSettings(UUIDModel):
2732
verbose_name=_('shared secret'),
2833
help_text=_('used for automatic registration of devices'),
2934
)
35+
context = JSONField(
36+
blank=True,
37+
default=dict,
38+
load_kwargs={'object_pairs_hook': collections.OrderedDict},
39+
dump_kwargs={'indent': 4},
40+
help_text=_(
41+
'This field can be used to add "Configuration Variables"' ' to the devices.'
42+
),
43+
verbose_name=_('Configuration Variables'),
44+
)
3045

3146
class Meta:
3247
verbose_name = _('Configuration management settings')
@@ -36,6 +51,22 @@ class Meta:
3651
def __str__(self):
3752
return self.organization.name
3853

54+
def get_context(self):
55+
return deepcopy(self.context)
56+
57+
def save(
58+
self, force_insert=False, force_update=False, using=None, update_fields=None
59+
):
60+
context_changed = False
61+
if not self._state.adding:
62+
db_instance = self.__class__.objects.only('context').get(id=self.id)
63+
context_changed = db_instance.context != self.context
64+
super().save(force_insert, force_update, using, update_fields)
65+
if context_changed:
66+
bulk_invalidate_config_get_cached_checksum.delay(
67+
{'device__organization_id': str(self.organization_id)}
68+
)
69+
3970

4071
class AbstractOrganizationLimits(models.Model):
4172
organization = models.OneToOneField(
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Generated by Django 3.2.20 on 2023-07-22 10:49
2+
3+
import collections
4+
5+
import jsonfield.fields
6+
from django.db import migrations
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('config', '0050_alter_vpnclient_unique_together'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='organizationconfigsettings',
18+
name='context',
19+
field=jsonfield.fields.JSONField(
20+
blank=True,
21+
default=dict,
22+
dump_kwargs={'indent': 4},
23+
help_text=(
24+
'This field can be used to add "Configuration Variables"'
25+
' to the devices.'
26+
),
27+
load_kwargs={'object_pairs_hook': collections.OrderedDict},
28+
verbose_name='Configuration Variables',
29+
),
30+
),
31+
]

openwisp_controller/config/static/config/js/widget.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,16 @@
6969
},
7070
getDefaultValues = function (isLoading=false) {
7171
var templatePks = $('input[name="config-0-templates"]').attr('value'),
72-
groupPk = $('#id_group').val();
72+
groupPk = $('#id_group').val(),
73+
orgPk = $('#id_organization').val();
7374
if (templatePks) {
7475
var payload = {pks: templatePks};
7576
if (groupPk) {
7677
payload.group = groupPk;
7778
}
79+
if (orgPk) {
80+
payload.organization = orgPk;
81+
}
7882
$.get(defaultValuesUrl, payload)
7983
.done( function (data) {
8084
updateContext(isLoading, data.default_values);
@@ -454,6 +458,9 @@
454458
$('#id_group').on('change', function() {
455459
getDefaultValues();
456460
});
461+
$('#id_organization').on('change', function() {
462+
getDefaultValues();
463+
});
457464
});
458465

459466
// deletes maxLength on ip address schema if address contains variable

openwisp_controller/config/tasks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,9 @@ def change_devices_templates(instance_id, model_name, **kwargs):
140140
backend=kwargs.pop('backend'),
141141
**kwargs,
142142
)
143+
144+
145+
@shared_task(soft_time_limit=7200)
146+
def bulk_invalidate_config_get_cached_checksum(query_params):
147+
Config = load_model('config', 'Config')
148+
Config.bulk_invalidate_get_cached_checksum(query_params)

openwisp_controller/config/tests/test_admin.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
DeviceGroup = load_model('config', 'DeviceGroup')
3939
Template = load_model('config', 'Template')
4040
Vpn = load_model('config', 'Vpn')
41+
OrganizationConfigSettings = load_model('config', 'OrganizationConfigSettings')
4142
Ca = load_model('django_x509', 'Ca')
4243
Cert = load_model('django_x509', 'Cert')
4344
User = get_user_model()
@@ -1428,9 +1429,24 @@ def test_clone_template(self):
14281429
self.assertEqual(LogEntry.objects.all().count(), 3)
14291430

14301431
def test_get_default_values(self):
1431-
t1 = self._create_template(name='t1', default_values={'name1': 'test1'})
1432+
org = self._get_org()
1433+
OrganizationConfigSettings.objects.create(
1434+
organization=org,
1435+
context={
1436+
'name1': 'organization variable',
1437+
'name3': 'should not appear',
1438+
},
1439+
)
1440+
t1 = self._create_template(
1441+
name='t1', default_values={'name1': 'test1'}, organization=org
1442+
)
14321443
group = self._create_device_group(
1433-
context={'name1': 'device group', 'name2': 'should not appear'}
1444+
organization=org,
1445+
context={
1446+
'name1': 'device group',
1447+
'name2': 'should not appear',
1448+
'name4': 'should not appear',
1449+
},
14341450
)
14351451
path = reverse('admin:get_default_values')
14361452

@@ -1459,12 +1475,43 @@ def test_get_default_values(self):
14591475
response_data = response.json()
14601476
self.assertEqual(response_data, expected)
14611477
self.assertNotIn('name2', response_data)
1478+
self.assertNotIn('name4', response_data)
1479+
1480+
with self.subTest('get default values conflicting with organization'):
1481+
with self.assertNumQueries(4):
1482+
response = self.client.get(
1483+
path, {'pks': f'{t1.pk}', 'organization': str(org.pk)}
1484+
)
1485+
self.assertEqual(response.status_code, 200)
1486+
expected = {'default_values': {'name1': 'organization variable'}}
1487+
response_data = response.json()
1488+
self.assertEqual(response_data, expected)
1489+
self.assertNotIn('name3', response_data)
1490+
1491+
with self.subTest('get default values conflicting with organization and group'):
1492+
with self.assertNumQueries(5):
1493+
response = self.client.get(
1494+
path,
1495+
{
1496+
'pks': f'{t1.pk}',
1497+
'group': str(group.pk),
1498+
'organization': str(org.pk),
1499+
},
1500+
)
1501+
self.assertEqual(response.status_code, 200)
1502+
expected = {'default_values': {'name1': 'device group'}}
1503+
response_data = response.json()
1504+
self.assertEqual(response_data, expected)
1505+
self.assertNotIn('name2', response_data)
1506+
self.assertNotIn('name3', response_data)
1507+
self.assertNotIn('name4', response_data)
14621508

14631509
def test_get_default_values_invalid_pks(self):
14641510
path = reverse('admin:get_default_values')
14651511
expected = {
14661512
'template': {'error': 'invalid template pks were received'},
1467-
'group': {'error': 'invalid group pk were received'},
1513+
'group': {'error': 'invalid group pk was received'},
1514+
'organization': {'error': 'invalid organization pk was received'},
14681515
}
14691516

14701517
with self.subTest('test with invalid template pk'):
@@ -1482,6 +1529,11 @@ def test_get_default_values_invalid_pks(self):
14821529
self.assertEqual(r.status_code, 400)
14831530
self.assertEqual(r.json(), expected['group'])
14841531

1532+
with self.subTest('test with invalid organization pk'):
1533+
r = self.client.get(path, {'organization': 'invalid', 'pks': str(uuid4())})
1534+
self.assertEqual(r.status_code, 400)
1535+
self.assertEqual(r.json(), expected['organization'])
1536+
14851537
def _test_system_context_field_helper(self, path):
14861538
r = self.client.get(path)
14871539
self.assertEqual(r.status_code, 200)
@@ -1524,7 +1576,6 @@ def test_no_system_context(self, *args):
15241576
self._create_template()
15251577
path = reverse(f'admin:{self.app_label}_template_add')
15261578
r = self.client.get(path)
1527-
print(app_settings.CONTEXT)
15281579
self.assertContains(
15291580
r, 'There are no system defined variables available right now'
15301581
)

openwisp_controller/config/tests/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ def test_device_download_api(self):
467467
d1 = self._create_device()
468468
self._create_config(device=d1)
469469
path = reverse('config_api:download_device_config', args=[d1.pk])
470-
with self.assertNumQueries(6):
470+
with self.assertNumQueries(8):
471471
r = self.client.get(path)
472472
self.assertEqual(r.status_code, 200)
473473

0 commit comments

Comments
 (0)