Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add create_or_update_tenant_config management command #182

Merged
merged 6 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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": "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
)
navinkarkera marked this conversation as resolved.
Show resolved Hide resolved
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