From 219da7714c7342169fe0980041d2581df2d0657e Mon Sep 17 00:00:00 2001 From: Keith Grootboom Date: Thu, 21 Jul 2022 16:49:38 +0200 Subject: [PATCH] feat: added new setting CUSTOM_RESOURCE_TEMPLATES_DIRECTORY This setting allows loading of Resource Templates from outside the edx-platform codebase. Operators will be able to add their own custom resource templates without needing to fork the codebase. (cherry picked from commit ed81774569fd0f1eb8eea5b46d40c60222ceaf1b) --- cms/envs/common.py | 10 ++ .../xmodule/tests/test_resource_templates.py | 31 ++++- common/lib/xmodule/xmodule/x_module.py | 119 +++++++++++++----- lms/envs/common.py | 10 ++ 4 files changed, 139 insertions(+), 31 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index edba66f8b200..e9af08e83860 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2026,6 +2026,16 @@ # .. toggle_creation_date: 2016-06-30 ENABLE_COMPREHENSIVE_THEMING = False +# .. setting_name: CUSTOM_RESOURCE_TEMPLATES_DIRECTORY +# .. setting_default: None +# .. setting_description: Path to an existing directory of YAML files containing +# html content to be used with the subclasses of xmodule.x_module.ResourceTemplates. +# Default example templates can be found in xmodule/templates/html. +# Note that the extension used is ".yaml" and not ".yml". +# See xmodule.x_module.ResourceTemplates for usage. +# "CUSTOM_RESOURCE_TEMPLATES_DIRECTORY" : null +CUSTOM_RESOURCE_TEMPLATES_DIRECTORY = None + ############################ Global Database Configuration ##################### DATABASE_ROUTERS = [ diff --git a/common/lib/xmodule/xmodule/tests/test_resource_templates.py b/common/lib/xmodule/xmodule/tests/test_resource_templates.py index e51f69ed30d5..742a7e9da199 100644 --- a/common/lib/xmodule/xmodule/tests/test_resource_templates.py +++ b/common/lib/xmodule/xmodule/tests/test_resource_templates.py @@ -1,12 +1,14 @@ """ Tests for xmodule.x_module.ResourceTemplates """ - - +import pathlib import unittest +from django.test import override_settings from xmodule.x_module import ResourceTemplates +CUSTOM_RESOURCE_TEMPLATES_DIRECTORY = pathlib.Path(__file__).parent.parent / "templates/" + class ResourceTemplatesTests(unittest.TestCase): """ @@ -28,6 +30,20 @@ def test_templates_no_suchdir(self): def test_get_template(self): assert TestClass.get_template('latex_html.yaml')['template_id'] == 'latex_html.yaml' + @override_settings(CUSTOM_RESOURCE_TEMPLATES_DIRECTORY=CUSTOM_RESOURCE_TEMPLATES_DIRECTORY) + def test_get_custom_template(self): + assert TestClassResourceTemplate.get_template('latex_html.yaml')['template_id'] == 'latex_html.yaml' + + @override_settings(CUSTOM_RESOURCE_TEMPLATES_DIRECTORY=CUSTOM_RESOURCE_TEMPLATES_DIRECTORY) + def test_custom_templates(self): + expected = { + 'latex_html.yaml', + 'zooming_image.yaml', + 'announcement.yaml', + 'anon_user_id.yaml'} + got = {t['template_id'] for t in TestClassResourceTemplate.templates()} + assert expected == got + class TestClass(ResourceTemplates): """ @@ -55,3 +71,14 @@ class TestClass2(TestClass): @classmethod def get_template_dir(cls): return 'foo' + + +class TestClassResourceTemplate(ResourceTemplates): + """ + Like TestClass, but `template_packages` contains a module that doesn't + have any templates. + + See `TestClass`. + """ + template_packages = ['capa.checker'] + template_dir_name = 'test' diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 9b8f898d1ab0..d646a114cc68 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -9,11 +9,12 @@ import yaml +from django.conf import settings from lazy import lazy from lxml import etree from opaque_keys.edx.asides import AsideDefinitionKeyV2, AsideUsageKeyV2 from opaque_keys.edx.keys import UsageKey -from pkg_resources import resource_exists, resource_isdir, resource_listdir, resource_string +from pkg_resources import resource_isdir, resource_string, resource_filename from web_fragments.fragment import Fragment from webob import Response from webob.multidict import MultiDict @@ -1031,11 +1032,48 @@ def policy_key(location): class ResourceTemplates: """ - Gets the templates associated w/ a containing cls. The cls must have a 'template_dir_name' attribute. - It finds the templates as directly in this directory under 'templates'. + Gets the yaml templates associated with a containing cls for display in the Studio. + + The cls must have a 'template_dir_name' attribute. It finds the templates as directly + in this directory under 'templates'. + + Additional templates can be loaded by setting the + CUSTOM_RESOURCE_TEMPLATES_DIRECTORY configuration setting. + + Note that a template must end with ".yaml" extension otherwise it will not be + loaded. """ template_packages = [__name__] + @classmethod + def _load_template(cls, template_path, template_id): + """ + Reads an loads the yaml content provided in the template_path and + return the content as a dictionary. + """ + if not os.path.exists(template_path): + return None + + with open(template_path) as file_object: + template = yaml.safe_load(file_object) + template['template_id'] = template_id + return template + + @classmethod + def _load_templates_in_dir(cls, dirpath): + """ + Lists every resource template found in the provided dirpath. + """ + templates = [] + for template_file in os.listdir(dirpath): + if not template_file.endswith('.yaml'): + log.warning("Skipping unknown template file %s", template_file) + continue + + template = cls._load_template(os.path.join(dirpath, template_file), template_file) + templates.append(template) + return templates + @classmethod def templates(cls): """ @@ -1043,23 +1081,15 @@ def templates(cls): to seed a module of this type. Expects a class attribute template_dir_name that defines the directory - inside the 'templates' resource directory to pull templates from + inside the 'templates' resource directory to pull templates from. """ - templates = [] - dirname = cls.get_template_dir() - if dirname is not None: - for pkg in cls.template_packages: - if not resource_isdir(pkg, dirname): - continue - for template_file in resource_listdir(pkg, dirname): - if not template_file.endswith('.yaml'): - log.warning("Skipping unknown template file %s", template_file) - continue - template_content = resource_string(pkg, os.path.join(dirname, template_file)) - template = yaml.safe_load(template_content) - template['template_id'] = template_file - templates.append(template) - return templates + templates = {} + + for dirpath in cls.get_template_dirpaths(): + for template in cls._load_templates_in_dir(dirpath): + templates[template['template_id']] = template + + return list(templates.values()) @classmethod def get_template_dir(cls): # lint-amnesty, pylint: disable=missing-function-docstring @@ -1076,22 +1106,53 @@ def get_template_dir(cls): # lint-amnesty, pylint: disable=missing-function-doc else: return None + @classmethod + def get_template_dirpaths(cls): + """ + Returns of list of directories containing resource templates. + """ + template_dirpaths = [] + template_dirname = cls.get_template_dir() + if template_dirname and resource_isdir(__name__, template_dirname): + template_dirpaths.append(resource_filename(__name__, template_dirname)) + + custom_template_dir = cls.get_custom_template_dir() + if custom_template_dir: + template_dirpaths.append(custom_template_dir) + return template_dirpaths + + @classmethod + def get_custom_template_dir(cls): + """ + If settings.CUSTOM_RESOURCE_TEMPLATES_DIRECTORY is defined, check if it has a + subdirectory named as the class's template_dir_name and return the full path. + """ + template_dir_name = getattr(cls, 'template_dir_name', None) + + if template_dir_name is None: + return + + resource_dir = settings.CUSTOM_RESOURCE_TEMPLATES_DIRECTORY + + if not resource_dir: + return None + + template_dir_path = os.path.join(resource_dir, template_dir_name) + + if os.path.exists(template_dir_path): + return template_dir_path + return None + @classmethod def get_template(cls, template_id): """ Get a single template by the given id (which is the file name identifying it w/in the class's template_dir_name) - """ - dirname = cls.get_template_dir() - if dirname is not None: - path = os.path.join(dirname, template_id) - for pkg in cls.template_packages: - if resource_exists(pkg, path): - template_content = resource_string(pkg, path) - template = yaml.safe_load(template_content) - template['template_id'] = template_id - return template + for directory in sorted(cls.get_template_dirpaths(), reverse=True): + abs_path = os.path.join(directory, template_id) + if os.path.exists(abs_path): + return cls._load_template(abs_path, template_id) class XModuleDescriptorToXBlockMixin: diff --git a/lms/envs/common.py b/lms/envs/common.py index 18a31ad0e3ad..b923b17a9dbb 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -4377,6 +4377,16 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # .. toggle_creation_date: 2016-06-30 ENABLE_COMPREHENSIVE_THEMING = False +# .. setting_name: CUSTOM_RESOURCE_TEMPLATES_DIRECTORY +# .. setting_default: None +# .. setting_description: Path to an existing directory of YAML files containing +# html content to be used with the subclasses of xmodule.x_module.ResourceTemplates. +# Default example templates can be found in xmodule/templates/html. +# Note that the extension used is ".yaml" and not ".yml". +# See xmodule.x_module.ResourceTemplates for usage. +# "CUSTOM_RESOURCE_TEMPLATES_DIRECTORY" : null +CUSTOM_RESOURCE_TEMPLATES_DIRECTORY = None + # API access management API_ACCESS_MANAGER_EMAIL = 'api-access@example.com' API_ACCESS_FROM_EMAIL = 'api-requests@example.com'