Skip to content

Commit

Permalink
feat: add create_or_update_tenant_config management command (#1)
Browse files Browse the repository at this point in the history
* feat: add create_or_update_tenant_config management command

* refactor: rename cmd edit_tenant_values to edit_microsite_values

* docs: update readme to add example of create_or_update_tenant_config command

* refactor: update configs instead of overwriting

* refactor: add override argument and update merge logic

* refactor: use inbuilt open method
  • Loading branch information
navinkarkera authored Jul 5, 2023
1 parent e04fbb1 commit 0f95406
Show file tree
Hide file tree
Showing 5 changed files with 343 additions and 19 deletions.
17 changes: 16 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
-------

Expand Down
145 changes: 145 additions & 0 deletions eox_tenant/management/commands/create_or_update_tenant_config.py
Original file line number Diff line number Diff line change
@@ -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": "[email protected]"}}' \
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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions eox_tenant/test/test_create_or_udpate_tenant_config.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 0f95406

Please sign in to comment.