diff --git a/README.rst b/README.rst index 823fe2ab..482cd5ad 100644 --- a/README.rst +++ b/README.rst @@ -130,7 +130,7 @@ Commands Synchronize Organizations ************************* -This comand will synchronize the course_org_filter values in lms_configs(TenantConfig model) or values(Microsite model) with the TenantOrganization registers, if the organization does not existe, it will be create, otherwise it will be add to the organizations model field. +This command will synchronize the course_org_filter values in lms_configs(TenantConfig model) or values(Microsite model) with the TenantOrganization registers, if the organization does not exist, it will be created, otherwise it will be add to the organizations model field. .. code-block:: bash @@ -139,6 +139,21 @@ This comand will synchronize the course_org_filter values in lms_configs(TenantC ./manage.py lms synchronize_organizations --model TenantConfig # only for TenantConfig ./manage.py lms synchronize_organizations --model Microsite # only for Microsite +Create/Edit tenant configuration +******************************** +`create_or_update_tenant_config` helps to add or edit ``TenantConfig`` and linked ``Routes`` via command line. + +.. code-block:: bash + + # this command will create/edit entry in TenantConfig with external_key lacolhost.com and update its JSONField(s) with passed json content. + ./manage.py lms create_or_update_tenant_config --external-key lacolhost.com --config '{"lms_configs": {"PLATFORM_NAME": "Lacolhost"}, "studio_configs": {"PLATFORM_NAME": "Lacolhost"}}' lacolhost.com studio.lacolhost.com preview.lacolhost.com + + # this command will create/edit entry in TenantConfig with external_key lacolhost.com and update its JSONField(s) with passed json config file content. + ./manage.py lms create_or_update_tenant_config --external-key lacolhost.com --config-file /tmp/some.json lacolhost.com studio.lacolhost.com preview.lacolhost.com + + # Same as above, but it will override configuration instead of updating it. + ./manage.py lms create_or_update_tenant_config --external-key lacolhost.com --config-file /tmp/some.json lacolhost.com studio.lacolhost.com preview.lacolhost.com --override + Caveats ------- diff --git a/eox_tenant/management/commands/create_or_update_tenant_config.py b/eox_tenant/management/commands/create_or_update_tenant_config.py new file mode 100644 index 00000000..47490247 --- /dev/null +++ b/eox_tenant/management/commands/create_or_update_tenant_config.py @@ -0,0 +1,145 @@ +""" +Create or updates the TenantConfig for given routes. +""" + +import json +import logging + +from django.core.management import BaseCommand +from jsonfield.fields import JSONField + +from eox_tenant.models import Route, TenantConfig + +LOG = logging.getLogger(__name__) + + +def load_json_from_file(filename): + """ + Loads json content from file. + """ + with open(filename, encoding='utf-8') as file: + return json.load(file) + + +class Command(BaseCommand): + """ + Management command to create or update TenantConfig. + + Examples: + # create/update tenant config and link 2 routes + - python manage.py create_or_update_tenant_config --external-key lacolhost.com \ + --config '{"lms_configs": {"PLATFORM_NAME": "Lacolhost", "CONTACT_EMAIL": "edx@example.com"}}' \ + lacolhost.com preview.lacolhost.com + + # Override existing lms_configs under a tenant, for example, below command will overwrite `lms_configs` + # with given dictionary instead of updating it. + - python manage.py create_or_update_tenant_config --external-key lacolhost.com \ + --config '{"lms_configs": {"PLATFORM_NAME": "New name"}}' lacolhost.com preview.lacolhost.com + + # create/update tenant config using json file + - python manage.py create_or_update_tenant_config --external-key lacolhost.com \ + --config-file /tmp/lms.json lacolhost.com preview.lacolhost.com + + # Link studio.lacolhost.com route to existing/empty tenant config with given external key + - python manage.py create_or_update_tenant_config --external-key lacolhost.com studio.lacolhost.com + + """ + help = 'Create or update TenantConfig' + + def add_arguments(self, parser): + parser.add_argument( + '--external-key', + dest='external_key', + required=True, + type=str, + help='External key of the tenant config' + ) + parser.add_argument('routes', nargs='+', help='Routes to link to this tenant config') + + parser.add_argument( + '--config', + type=json.loads, + help="Enter JSON tenant configurations", + required=False + ) + parser.add_argument( + '-f', + '--config-file', + type=load_json_from_file, + dest='config_file_data', + help="Enter the path to the JSON file containing the tenant configuration", + required=False + ) + parser.add_argument( + '--override', + dest='override', + action='store_true', + required=False + ) + + def merge_dict(self, base_dict, override): + """ + Merge two nested dicts. + """ + if isinstance(base_dict, dict) and isinstance(override, dict): + for key, value in override.items(): + base_dict[key] = self.merge_dict(base_dict.get(key, {}), value) + return base_dict + + return override + + def handle(self, *args, **options): + """ + Create or update TenantConfig and link related routes. + """ + external_key = options['external_key'] + routes = options['routes'] + configuration = options.get('config') + config_file_data = options.get('config_file_data') + tenant_configuration_values = configuration or config_file_data + override = options.get('override') + # pylint: disable=no-member,protected-access + external_key_length = TenantConfig._meta.get_field("external_key").max_length + if external_key: + if len(str(external_key)) > external_key_length: + LOG.warning( + "The external_key %s is too long, truncating to %s" + " characters. Please update external_key in admin.", + external_key, + external_key_length + ) + # trim name as the column has a limit of 63 characters + external_key = external_key[:external_key_length] + tenant, created = TenantConfig.objects.get_or_create( + external_key=external_key, + ) + if created: + LOG.info("Tenant does not exist. Created new tenant: '%s'", tenant.external_key) + else: + LOG.info("Found existing tenant for: '%s'", tenant.external_key) + + # split out lms, studio, theme, meta from configuration json + if tenant_configuration_values: + for field in TenantConfig._meta.get_fields(): + if isinstance(field, JSONField): + name = field.name + value = tenant_configuration_values.get(name) + if value is not None: + if override: + setattr(tenant, name, value) + else: + base_value = getattr(tenant, name, {}) + merged = self.merge_dict(base_value, value) + setattr(tenant, name, merged) + + tenant.save() + # next add routes and link them + for route in routes: + route, created = Route.objects.update_or_create( + domain=route, + defaults={"config": tenant} + ) + if created: + LOG.info("Route does not exist. Created new route: '%s'", route.domain) + else: + LOG.info("Found existing route for: '%s'", route.domain) diff --git a/eox_tenant/management/commands/edit_tenant_values.py b/eox_tenant/management/commands/edit_microsite_values.py similarity index 93% rename from eox_tenant/management/commands/edit_tenant_values.py rename to eox_tenant/management/commands/edit_microsite_values.py index 68d0c77c..f9da4131 100644 --- a/eox_tenant/management/commands/edit_tenant_values.py +++ b/eox_tenant/management/commands/edit_microsite_values.py @@ -44,11 +44,12 @@ class Command(BaseCommand): Main class handling the execution of the command to alter the sites by adding or removing keys Examples: - - python manage.py lms edit_tenant_values --add EDNX_USE_SIGNAL True - - python manage.py lms edit_tenant_values --delete EDNX_USE_SIGNAL + - python manage.py lms edit_microsite_values --add EDNX_USE_SIGNAL True + - python manage.py lms edit_microsite_values --delete EDNX_USE_SIGNAL Advanced example: - - python manage.py lms edit_tenant_values --pattern yoursite.com -v 2 --add NESTED.KEY.NAME {interpolated_value} -f + - python manage.py lms edit_microsite_values --pattern yoursite.com -v 2 \ + --add NESTED.KEY.NAME {interpolated_value} -f """ help = """ Exposes a cli to perform bulk modification of eox_tenant sites diff --git a/eox_tenant/test/test_create_or_udpate_tenant_config.py b/eox_tenant/test/test_create_or_udpate_tenant_config.py new file mode 100644 index 00000000..decd7c4c --- /dev/null +++ b/eox_tenant/test/test_create_or_udpate_tenant_config.py @@ -0,0 +1,163 @@ +"""This module include a class that checks the command create_or_update_tenant_config.py""" +import json +import tempfile + +from django.core.management import CommandError, call_command +from django.test import TestCase + +from eox_tenant.models import Route, TenantConfig + + +class CreateOrUpdateTenantConfigTestCase(TestCase): + """ This class checks the command create_or_update_tenant_config.py""" + + def setUp(self): + """This method creates TenantConfig objects in database""" + self.test_conf = { + "lms_configs": {"NEW KEY": "value-updated", "NESTED_KEY": {"key": "value"}}, + "studio_configs": {"STUDIO_KEY": "value", "STUDIO_NESTED_KEY": {"key": "value"}, } + } + self.external_key = "test" + TenantConfig.objects.create( + external_key=self.external_key, + lms_configs={ + "KEY": "value", + }, + ) + + def test_command_happy_path_with_cmd_config(self): + """Tests that command runs successfully if config is passed via cmd""" + self.assertFalse(TenantConfig.objects.filter(external_key="new.key").exists()) + self.assertFalse(Route.objects.filter(domain__contains="test.domain").exists()) + call_command( + "create_or_update_tenant_config", + "--external-key", + "new.key", + "--config", + json.dumps(self.test_conf), + "test.domain", + "studio.test.domain" + ) + created_config = TenantConfig.objects.get(external_key="new.key") + self.assertTrue(created_config.lms_configs == self.test_conf["lms_configs"]) + self.assertTrue(created_config.studio_configs == self.test_conf["studio_configs"]) + created_routes = Route.objects.filter(domain__contains="test.domain").count() + self.assertTrue(created_routes == 2) + + def test_command_happy_path_with_file_config(self): + """Tests that command runs successfully if config is passed via file""" + self.assertFalse(TenantConfig.objects.filter(external_key="new.key").exists()) + self.assertFalse(Route.objects.filter(domain__contains="test.domain").exists()) + with tempfile.NamedTemporaryFile('w') as fp: + fp.write(json.dumps(self.test_conf)) + fp.seek(0) + call_command( + "create_or_update_tenant_config", + "--external-key", + "new.key", + "--config-file", + fp.name, + "test.domain", + "studio.test.domain" + ) + created_config = TenantConfig.objects.get(external_key="new.key") + self.assertTrue(created_config.lms_configs == self.test_conf["lms_configs"]) + created_routes = Route.objects.filter(domain__contains="test.domain").count() + self.assertTrue(created_routes == 2) + + def test_command_invalid_config(self): + """Tests that command raises if config is invalid""" + self.assertFalse(TenantConfig.objects.filter(external_key="new.key").exists()) + self.assertFalse(Route.objects.filter(domain__contains="test.domain").exists()) + with self.assertRaises(CommandError): + call_command( + "create_or_update_tenant_config", + "--external-key", + "new.key", + "--config", + '{"KEY": "value, "NESTED_KEY": {"key": "value"}}', + "test.domain", + "studio.test.domain" + ) + self.assertFalse(TenantConfig.objects.filter(external_key="new.key").exists()) + self.assertFalse(Route.objects.filter(domain__contains="test.domain").exists()) + + def test_command_with_no_config(self): + """ + Tests that command works even if config is not passed, + i.e. it adds/updates an entry with external_key and links given routes. + """ + self.assertFalse(TenantConfig.objects.filter(external_key="new.key").exists()) + self.assertFalse(Route.objects.filter(domain__contains="test.domain").exists()) + call_command( + "create_or_update_tenant_config", + "--external-key", + "new.key", + "test.domain", + "studio.test.domain" + ) + self.assertTrue(TenantConfig.objects.filter(external_key="new.key").exists()) + self.assertTrue(Route.objects.filter(domain__contains="test.domain").exists()) + + def test_command_with_long_external_key(self): + """Tests that command runs successfully even if external key is longer than limit.""" + long_external_key = "areallyreallyreallyreallyreallyreallylongexternalkey" + self.assertFalse( + TenantConfig.objects.filter(external_key__in=[long_external_key, long_external_key[:63]]).exists() + ) + self.assertFalse(Route.objects.filter(domain__contains="test.domain").exists()) + call_command( + "create_or_update_tenant_config", + "--external-key", + long_external_key, + "--config", + json.dumps(self.test_conf), + "test.domain", + "studio.test.domain" + ) + created_config = TenantConfig.objects.get(external_key=long_external_key[:63]) + self.assertTrue(created_config.lms_configs == self.test_conf["lms_configs"]) + created_routes = Route.objects.filter(domain__contains="test.domain").count() + self.assertTrue(created_routes == 2) + + def test_command_update_existing_tenant(self): + """Tests that command successfully updates existing TenantConfig.""" + config = TenantConfig.objects.get(external_key=self.external_key) + self.assertTrue(config.lms_configs == {"KEY": "value"}) + self.assertTrue(config.studio_configs == {}) + call_command( + "create_or_update_tenant_config", + "--external-key", + self.external_key, + "--config", + json.dumps(self.test_conf), + "test.domain", + "studio.test.domain" + ) + updated_config = TenantConfig.objects.get(external_key=self.external_key) + for key, value in self.test_conf["lms_configs"].items(): + self.assertTrue(updated_config.lms_configs[key] == value) + self.assertTrue(updated_config.lms_configs["KEY"] == "value") + self.assertTrue(updated_config.studio_configs == self.test_conf["studio_configs"]) + created_routes = Route.objects.filter(domain__contains="test.domain").count() + self.assertTrue(created_routes == 2) + + def test_command_update_existing_tenant_override(self): + """Tests that command successfully replaces existing TenantConfig with override param.""" + config = TenantConfig.objects.get(external_key=self.external_key) + self.assertTrue(config.studio_configs == {}) + new_conf = {"lms_configs": {"NEW_KEY": "new value"}} + call_command( + "create_or_update_tenant_config", + "--external-key", + self.external_key, + "--config", + json.dumps(new_conf), + "test.domain", + "studio.test.domain", + "--override", + ) + updated_config = TenantConfig.objects.get(external_key=self.external_key) + self.assertTrue(updated_config.lms_configs == new_conf["lms_configs"]) + created_routes = Route.objects.filter(domain__contains="test.domain").count() + self.assertTrue(created_routes == 2) diff --git a/eox_tenant/test/test_edit_tenant_values.py b/eox_tenant/test/test_edit_microsite_values.py similarity index 67% rename from eox_tenant/test/test_edit_tenant_values.py rename to eox_tenant/test/test_edit_microsite_values.py index 56e8701f..ae3c9a47 100644 --- a/eox_tenant/test/test_edit_tenant_values.py +++ b/eox_tenant/test/test_edit_microsite_values.py @@ -8,7 +8,7 @@ class EditTenantValuesTestCase(TestCase): - """ This class checks the command edit_tenant_values.py""" + """ This class checks the command edit_microsite_values.py""" def setUp(self): """This method creates Microsite objects in database""" @@ -20,49 +20,49 @@ def setUp(self): "NESTED_KEY": {"key": "value"}, }) - @patch('eox_tenant.management.commands.edit_tenant_values.input', return_value='y') + @patch('eox_tenant.management.commands.edit_microsite_values.input', return_value='y') def test_command_can_be_called(self, _): """Tests that we can actually run the command""" - call_command('edit_tenant_values') + call_command('edit_microsite_values') - @patch('eox_tenant.management.commands.edit_tenant_values.input', return_value='n') + @patch('eox_tenant.management.commands.edit_microsite_values.input', return_value='n') def test_command_exec_confirmation_false(self, _): """Tests that when the confirmation returns other than 'y' we raise the abort error""" with self.assertRaises(CommandError): - call_command('edit_tenant_values') + call_command('edit_microsite_values') - @patch('eox_tenant.management.commands.edit_tenant_values.input', return_value='y') + @patch('eox_tenant.management.commands.edit_microsite_values.input', return_value='y') def test_command_exec_confirmation_add(self, _): """Tests that we can add a new key""" - call_command('edit_tenant_values', '--add', 'NEW_KEY', 'NEW_VALUE') + call_command('edit_microsite_values', '--add', 'NEW_KEY', 'NEW_VALUE') tenant = Microsite.objects.get(key='test') self.assertIn('NEW_KEY', tenant.values) self.assertEqual('NEW_VALUE', tenant.values.get('NEW_KEY')) - @patch('eox_tenant.management.commands.edit_tenant_values.input', return_value='y') + @patch('eox_tenant.management.commands.edit_microsite_values.input', return_value='y') def test_command_exec_confirmation_add_nested(self, _): """Tests that we can add a new nested key""" - call_command('edit_tenant_values', '--add', 'NEW_KEY.nested', 'NEW_VALUE') + call_command('edit_microsite_values', '--add', 'NEW_KEY.nested', 'NEW_VALUE') tenant = Microsite.objects.get(key='test') self.assertIn('nested', tenant.values.get('NEW_KEY')) self.assertEqual('NEW_VALUE', tenant.values.get('NEW_KEY').get('nested')) - @patch('eox_tenant.management.commands.edit_tenant_values.input', return_value='y') + @patch('eox_tenant.management.commands.edit_microsite_values.input', return_value='y') def test_command_exec_confirmation_delete(self, _): """Tests that we can remove a key""" - call_command('edit_tenant_values', '--delete', 'KEY') + call_command('edit_microsite_values', '--delete', 'KEY') tenant = Microsite.objects.get(key='test') self.assertNotIn('KEY', tenant.values) - @patch('eox_tenant.management.commands.edit_tenant_values.input', return_value='y') + @patch('eox_tenant.management.commands.edit_microsite_values.input', return_value='y') def test_command_exec_confirmation_delete_chain(self, _): """Tests that we can remove a nested key""" - call_command('edit_tenant_values', '--delete', 'NESTED_KEY.key') + call_command('edit_microsite_values', '--delete', 'NESTED_KEY.key') tenant = Microsite.objects.get(key='test') self.assertIn('NESTED_KEY', tenant.values) self.assertNotIn('key', tenant.values.get('NESTED_KEY')) - @patch('eox_tenant.management.commands.edit_tenant_values.input', return_value='y') + @patch('eox_tenant.management.commands.edit_microsite_values.input', return_value='y') def test_command_exec_confirmation_pattern(self, _): """Tests that we can affect only the sites defined by a pattern in their subdomain""" Microsite.objects.create( @@ -73,7 +73,7 @@ def test_command_exec_confirmation_pattern(self, _): "NESTED_KEY": {"key": "value"}, }) - call_command('edit_tenant_values', '--delete', 'KEY', '--pattern', 'second.test.prod.edunext.co') + call_command('edit_microsite_values', '--delete', 'KEY', '--pattern', 'second.test.prod.edunext.co') tenant1 = Microsite.objects.get(key='test') tenant2 = Microsite.objects.get(key='test2')