498 lines
19 KiB
Python
498 lines
19 KiB
Python
"""
|
|
Module that provides a connection to the ModuleStore specified in the django settings.
|
|
|
|
Passes settings.MODULESTORE as kwargs to MongoModuleStore
|
|
"""
|
|
|
|
from contextlib import contextmanager
|
|
from importlib import import_module
|
|
import importlib.resources as resources
|
|
import gettext
|
|
import logging
|
|
|
|
import re # lint-amnesty, pylint: disable=wrong-import-order
|
|
|
|
from django.conf import settings
|
|
|
|
# This configuration must be executed BEFORE any additional Django imports. Otherwise, the imports may fail due to
|
|
# Django not being configured properly. This mostly applies to tests.
|
|
if not settings.configured:
|
|
settings.configure()
|
|
|
|
from django.contrib.staticfiles.storage import staticfiles_storage # lint-amnesty, pylint: disable=wrong-import-position
|
|
from django.core.cache import caches, InvalidCacheBackendError # lint-amnesty, pylint: disable=wrong-import-position
|
|
import django.dispatch # lint-amnesty, pylint: disable=wrong-import-position
|
|
import django.utils # lint-amnesty, pylint: disable=wrong-import-position
|
|
from django.utils.translation import get_language, to_locale # lint-amnesty, pylint: disable=wrong-import-position
|
|
from edx_django_utils.cache import DEFAULT_REQUEST_CACHE # lint-amnesty, pylint: disable=wrong-import-position
|
|
|
|
from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-position
|
|
from xmodule.modulestore.draft_and_published import BranchSettingMixin # lint-amnesty, pylint: disable=wrong-import-position
|
|
from xmodule.modulestore.mixed import MixedModuleStore # lint-amnesty, pylint: disable=wrong-import-position
|
|
from xmodule.util.xmodule_django import get_current_request_hostname # lint-amnesty, pylint: disable=wrong-import-position
|
|
|
|
from .api import ( # lint-amnesty, pylint: disable=wrong-import-position
|
|
get_javascript_i18n_file_name,
|
|
get_javascript_i18n_file_path,
|
|
get_python_locale_root,
|
|
get_xblock_root_module_name,
|
|
)
|
|
|
|
# We also may not always have the current request user (crum) module available
|
|
try:
|
|
from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
|
|
from crum import get_current_user
|
|
|
|
HAS_USER_SERVICE = True
|
|
except ImportError:
|
|
HAS_USER_SERVICE = False
|
|
|
|
try:
|
|
from common.djangoapps.xblock_django.api import disabled_xblocks
|
|
except ImportError:
|
|
disabled_xblocks = None
|
|
|
|
log = logging.getLogger(__name__)
|
|
ASSET_IGNORE_REGEX = getattr(settings, "ASSET_IGNORE_REGEX", r"(^\._.*$)|(^\.DS_Store$)|(^.*~$)")
|
|
|
|
|
|
class SwitchedSignal(django.dispatch.Signal):
|
|
"""
|
|
SwitchedSignal is like a normal Django signal, except that you can turn it
|
|
on and off. This is especially useful for tests where we want to be able to
|
|
isolate signals and disable expensive operations that are irrelevant to
|
|
what's being tested (like everything that triggers off of a course publish).
|
|
|
|
SwitchedSignals default to being on. You should be very careful if you ever
|
|
turn one off -- the only instances of this class are shared class attributes
|
|
of `SignalHandler`. You have to make sure that you re-enable the signal when
|
|
you're done, or else you may permanently turn that signal off for that
|
|
process. I can't think of any reason you'd want to disable signals outside
|
|
of running tests.
|
|
"""
|
|
def __init__(self, name, *args, **kwargs):
|
|
"""
|
|
The `name` parameter exists only to make debugging more convenient.
|
|
|
|
All other args are passed to the constructor for django.dispatch.Signal.
|
|
"""
|
|
super().__init__(*args, **kwargs)
|
|
self.name = name
|
|
self._allow_signals = True
|
|
|
|
def disable(self):
|
|
"""
|
|
Turn off signal sending.
|
|
|
|
All calls to send/send_robust will no-op.
|
|
"""
|
|
self._allow_signals = False
|
|
|
|
def enable(self):
|
|
"""
|
|
Turn on signal sending.
|
|
|
|
Calls to send/send_robust will behave like normal Django Signals.
|
|
"""
|
|
self._allow_signals = True
|
|
|
|
@contextmanager
|
|
def for_state(self, *, is_enabled: bool):
|
|
"""
|
|
Set signal handling to be on or off for the duration of the context.
|
|
"""
|
|
old_state = self._allow_signals
|
|
try:
|
|
self._allow_signals = is_enabled
|
|
yield
|
|
finally:
|
|
self._allow_signals = old_state
|
|
|
|
def send(self, *args, **kwargs):
|
|
"""
|
|
See `django.dispatch.Signal.send()`
|
|
|
|
This method will no-op and return an empty list if the signal has been
|
|
disabled.
|
|
"""
|
|
log.debug(
|
|
"SwitchedSignal %s's send() called with args %s, kwargs %s - %s",
|
|
self.name,
|
|
args,
|
|
kwargs,
|
|
"ALLOW" if self._allow_signals else "BLOCK"
|
|
)
|
|
if self._allow_signals:
|
|
return super().send(*args, **kwargs)
|
|
return []
|
|
|
|
def send_robust(self, *args, **kwargs):
|
|
"""
|
|
See `django.dispatch.Signal.send_robust()`
|
|
|
|
This method will no-op and return an empty list if the signal has been
|
|
disabled.
|
|
"""
|
|
log.debug(
|
|
"SwitchedSignal %s's send_robust() called with args %s, kwargs %s - %s",
|
|
self.name,
|
|
args,
|
|
kwargs,
|
|
"ALLOW" if self._allow_signals else "BLOCK"
|
|
)
|
|
if self._allow_signals:
|
|
return super().send_robust(*args, **kwargs)
|
|
return []
|
|
|
|
def __repr__(self):
|
|
return f"SwitchedSignal('{self.name}')"
|
|
|
|
|
|
class SignalHandler:
|
|
"""
|
|
This class is to allow the modulestores to emit signals that can be caught
|
|
by other parts of the Django application. If your app needs to do something
|
|
every time a course is published (e.g. search indexing), you can listen for
|
|
that event and kick off a celery task when it happens.
|
|
|
|
To listen for a signal, do the following::
|
|
|
|
from django.dispatch import receiver
|
|
from celery import shared_task
|
|
from edx_django_utils.monitoring import set_code_owner_attribute
|
|
from xmodule.modulestore.django import modulestore, SignalHandler
|
|
|
|
@receiver(SignalHandler.course_published)
|
|
def listen_for_course_publish(sender, course_key, **kwargs):
|
|
do_my_expensive_update.delay(course_key)
|
|
|
|
@shared_task()
|
|
@set_code_owner_attribute
|
|
def do_my_expensive_update(course_key):
|
|
# ...
|
|
|
|
Things to note:
|
|
|
|
1. We receive using the Django Signals mechanism.
|
|
2. The sender is going to be the class of the modulestore sending it.
|
|
3. The names of your handler function's parameters *must* be "sender" and "course_key".
|
|
4. Always have **kwargs in your signal handler, as new things may be added.
|
|
5. The thing that listens for the signal lives in process, but should do
|
|
almost no work. Its main job is to kick off the celery task that will
|
|
do the actual work.
|
|
"""
|
|
|
|
# If you add a new signal, please don't forget to add it to the _mapping
|
|
# as well.
|
|
pre_publish = SwitchedSignal("pre_publish")
|
|
course_published = SwitchedSignal("course_published")
|
|
course_deleted = SwitchedSignal("course_deleted")
|
|
library_updated = SwitchedSignal("library_updated")
|
|
item_deleted = SwitchedSignal("item_deleted")
|
|
|
|
_mapping = {
|
|
signal.name: signal
|
|
for signal
|
|
in [pre_publish, course_published, course_deleted, library_updated, item_deleted]
|
|
}
|
|
|
|
def __init__(self, modulestore_class):
|
|
self.modulestore_class = modulestore_class
|
|
|
|
@classmethod
|
|
def all_signals(cls):
|
|
"""Return a list with all our signals in it."""
|
|
return cls._mapping.values()
|
|
|
|
@classmethod
|
|
def signal_by_name(cls, signal_name):
|
|
"""Given a signal name, return the appropriate signal."""
|
|
return cls._mapping[signal_name]
|
|
|
|
def send(self, signal_name, **kwargs):
|
|
"""
|
|
Send the signal to the receivers.
|
|
"""
|
|
signal = self._mapping[signal_name]
|
|
responses = signal.send_robust(sender=self.modulestore_class, **kwargs)
|
|
|
|
for receiver, response in responses:
|
|
log.info('Sent %s signal to %s with kwargs %s. Response was: %s', signal_name, receiver, kwargs, response)
|
|
|
|
|
|
# to allow easy imports
|
|
globals().update({sig.name.upper(): sig for sig in SignalHandler.all_signals()})
|
|
|
|
|
|
def load_function(path):
|
|
"""
|
|
Load a function by name.
|
|
|
|
Arguments:
|
|
path: String of the form 'path.to.module.function'. Strings of the form
|
|
'path.to.module:Class.function' are also valid.
|
|
|
|
Returns:
|
|
The imported object 'function'.
|
|
"""
|
|
if ':' in path:
|
|
module_path, _, method_path = path.rpartition(':')
|
|
module = import_module(module_path)
|
|
|
|
class_name, method_name = method_path.split('.')
|
|
_class = getattr(module, class_name)
|
|
function = getattr(_class, method_name)
|
|
else:
|
|
module_path, _, name = path.rpartition('.')
|
|
function = getattr(import_module(module_path), name)
|
|
|
|
return function
|
|
|
|
|
|
def create_modulestore_instance(
|
|
engine,
|
|
content_store,
|
|
doc_store_config,
|
|
options,
|
|
i18n_service=None,
|
|
fs_service=None,
|
|
user_service=None,
|
|
signal_handler=None,
|
|
):
|
|
"""
|
|
This will return a new instance of a modulestore given an engine and options
|
|
"""
|
|
# Import is placed here to avoid model import at project startup.
|
|
import xblock.reference.plugins
|
|
|
|
class_ = load_function(engine)
|
|
|
|
_options = {}
|
|
_options.update(options)
|
|
|
|
FUNCTION_KEYS = ['render_template']
|
|
for key in FUNCTION_KEYS:
|
|
if key in _options and isinstance(_options[key], str):
|
|
_options[key] = load_function(_options[key])
|
|
|
|
request_cache = DEFAULT_REQUEST_CACHE
|
|
|
|
try:
|
|
metadata_inheritance_cache = caches['mongo_metadata_inheritance']
|
|
except InvalidCacheBackendError:
|
|
metadata_inheritance_cache = caches['default']
|
|
|
|
if issubclass(class_, MixedModuleStore):
|
|
_options['create_modulestore_instance'] = create_modulestore_instance
|
|
|
|
if issubclass(class_, BranchSettingMixin):
|
|
_options.setdefault('branch_setting_func', _get_modulestore_branch_setting)
|
|
|
|
if HAS_USER_SERVICE and not user_service:
|
|
xb_user_service = DjangoXBlockUserService(get_current_user())
|
|
else:
|
|
xb_user_service = None
|
|
|
|
xblock_field_data_wrappers = [load_function(path) for path in settings.XBLOCK_FIELD_DATA_WRAPPERS]
|
|
|
|
def fetch_disabled_xblock_types():
|
|
"""
|
|
Get the disabled xblock names, using the request_cache if possible to avoid hitting
|
|
a database every time the list is needed.
|
|
"""
|
|
# If the import could not be loaded, return an empty list.
|
|
if disabled_xblocks is None:
|
|
return []
|
|
|
|
if 'disabled_xblock_types' not in request_cache.data:
|
|
request_cache.data['disabled_xblock_types'] = [block.name for block in disabled_xblocks()]
|
|
return request_cache.data['disabled_xblock_types']
|
|
|
|
return class_(
|
|
contentstore=content_store,
|
|
metadata_inheritance_cache_subsystem=metadata_inheritance_cache,
|
|
request_cache=request_cache,
|
|
xblock_mixins=getattr(settings, 'XBLOCK_MIXINS', ()),
|
|
xblock_field_data_wrappers=xblock_field_data_wrappers,
|
|
disabled_xblock_types=fetch_disabled_xblock_types,
|
|
doc_store_config=doc_store_config,
|
|
i18n_service=i18n_service or XBlockI18nService,
|
|
fs_service=fs_service or xblock.reference.plugins.FSService(),
|
|
user_service=user_service or xb_user_service,
|
|
signal_handler=signal_handler or SignalHandler(class_),
|
|
**_options
|
|
)
|
|
|
|
|
|
# A singleton instance of the Mixed Modulestore
|
|
_MIXED_MODULESTORE = None
|
|
|
|
|
|
def modulestore():
|
|
"""
|
|
Returns the Mixed modulestore
|
|
"""
|
|
global _MIXED_MODULESTORE # pylint: disable=global-statement
|
|
if _MIXED_MODULESTORE is None:
|
|
_MIXED_MODULESTORE = create_modulestore_instance(
|
|
settings.MODULESTORE['default']['ENGINE'],
|
|
contentstore(),
|
|
settings.MODULESTORE['default'].get('DOC_STORE_CONFIG', {}),
|
|
settings.MODULESTORE['default'].get('OPTIONS', {})
|
|
)
|
|
|
|
if settings.FEATURES.get('CUSTOM_COURSES_EDX'):
|
|
# TODO: This import prevents a circular import issue, but is
|
|
# symptomatic of a lib having a dependency on code in lms. This
|
|
# should be updated to have a setting that enumerates modulestore
|
|
# wrappers and then uses that setting to wrap the modulestore in
|
|
# appropriate wrappers depending on enabled features.
|
|
from lms.djangoapps.ccx.modulestore import CCXModulestoreWrapper
|
|
_MIXED_MODULESTORE = CCXModulestoreWrapper(_MIXED_MODULESTORE)
|
|
|
|
return _MIXED_MODULESTORE
|
|
|
|
|
|
def clear_existing_modulestores():
|
|
"""
|
|
Clear the existing modulestore instances, causing
|
|
them to be re-created when accessed again.
|
|
|
|
This is useful for flushing state between unit tests.
|
|
"""
|
|
global _MIXED_MODULESTORE # pylint: disable=global-statement
|
|
_MIXED_MODULESTORE = None
|
|
|
|
|
|
class XBlockI18nService:
|
|
"""
|
|
Implement the XBlock runtime "i18n" service.
|
|
|
|
Mostly a pass-through to Django's translation module.
|
|
django.utils.translation implements the gettext.Translations interface (it
|
|
has ugettext, ungettext, etc), so we can use it directly as the runtime
|
|
i18n service.
|
|
|
|
This service supports OEP-58 translations (https://docs.openedx.org/en/latest/developers/concepts/oep58.html)
|
|
that are pulled via atlas.
|
|
"""
|
|
def __init__(self, block=None):
|
|
"""
|
|
Attempt to load an XBlock-specific GNU gettext translation using the XBlock's own domain
|
|
translation catalog.
|
|
If we can't locate the domain translation catalog then we fall-back onto
|
|
django.utils.translation, which will point to the system's own domain translation catalog
|
|
This effectively achieves translations by coincidence for an XBlock which does not provide
|
|
its own dedicated translation catalog along with its implementation.
|
|
"""
|
|
self.translator = django.utils.translation
|
|
if block:
|
|
xblock_locale_domain, xblock_locale_dir = self.get_python_locale(block)
|
|
selected_language = get_language()
|
|
|
|
if xblock_locale_dir:
|
|
try:
|
|
self.translator = gettext.translation(
|
|
xblock_locale_domain,
|
|
xblock_locale_dir,
|
|
[to_locale(selected_language if selected_language else settings.LANGUAGE_CODE)]
|
|
)
|
|
except OSError:
|
|
# Fall back to the default Django translator if the XBlock translator is not found.
|
|
pass
|
|
|
|
def get_python_locale(self, block):
|
|
"""
|
|
Return the XBlock locale directory with the domain name.
|
|
|
|
Return:
|
|
(domain, locale_path): A tuple of the domain name and the XBlock locale directory.
|
|
|
|
This method looks for translations in two locations:
|
|
- First it looks for `atlas` translations in get_python_locale_root().
|
|
- Alternatively, it looks for bundled translations in the XBlock pip package which are
|
|
found at <python_environment_xblock_root>/conf/locale/<language>/LC_MESSAGES/<domain>.po|mo
|
|
"""
|
|
xblock_module_name = get_xblock_root_module_name(block)
|
|
xblock_locale_path = get_python_locale_root() / xblock_module_name
|
|
|
|
# OEP-58 translations are pulled via atlas and takes precedence if exists.
|
|
if xblock_locale_path.isdir():
|
|
# The `django` domain is used for XBlocks consistent with the other repositories.
|
|
return 'django', xblock_locale_path
|
|
|
|
# Pre-OEP-58 translations within the XBlock pip packages are deprecated but supported.
|
|
with resources.as_file(resources.files(xblock_module_name) / 'translations') as deprecated_xblock_locale_path:
|
|
# The `text` domain was used for XBlocks pre-OEP-58.
|
|
return 'text', str(deprecated_xblock_locale_path)
|
|
|
|
def get_javascript_i18n_catalog_url(self, block):
|
|
"""
|
|
Return the XBlock compiled JavaScript translations catalog static url.
|
|
|
|
Return:
|
|
str: The static url to the JavaScript translations catalog, otherwise None.
|
|
"""
|
|
xblock_module_name = get_xblock_root_module_name(block)
|
|
language_name = get_language() # Returns language name e.g. `de` or `de-de`.
|
|
locale = to_locale(language_name) # Use the `de` or `de_DE` format for the locale directory.
|
|
|
|
if get_javascript_i18n_file_path(xblock_module_name, locale).exists():
|
|
relative_file_path = get_javascript_i18n_file_name(xblock_module_name, locale)
|
|
return staticfiles_storage.url(relative_file_path)
|
|
return None
|
|
|
|
def __getattr__(self, name):
|
|
name = 'gettext' if name == 'ugettext' else name
|
|
return getattr(self.translator, name)
|
|
|
|
def strftime(self, *args, **kwargs):
|
|
"""
|
|
A locale-aware implementation of strftime.
|
|
"""
|
|
# This is the wrong place to import this function. I'm putting it here
|
|
# because the xmodule test suite can't import this module, because
|
|
# Django is not available in that suite. This function isn't called in
|
|
# that suite, so this hides the import so the test won't fail.
|
|
#
|
|
# As I said, this is wrong. But Cale says this code will soon be
|
|
# refactored to a place that will be right, and the code can be made
|
|
# right there. If you are reading this comment after April 1, 2014,
|
|
# then Cale was a liar.
|
|
from common.djangoapps.util.date_utils import strftime_localized
|
|
|
|
return strftime_localized(*args, **kwargs)
|
|
|
|
|
|
def _get_modulestore_branch_setting():
|
|
"""
|
|
Returns the branch setting for the module store from the current Django request if configured,
|
|
else returns the branch value from the configuration settings if set,
|
|
else returns None
|
|
|
|
The value of the branch setting is cached in a thread-local variable so it is not repeatedly recomputed
|
|
"""
|
|
|
|
def get_branch_setting():
|
|
"""
|
|
Finds and returns the branch setting based on the Django request and the configuration settings
|
|
"""
|
|
branch = None
|
|
hostname = get_current_request_hostname()
|
|
if hostname:
|
|
# get mapping information which is defined in configurations
|
|
mappings = getattr(settings, 'HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS', None)
|
|
|
|
# compare hostname against the regex expressions set of mappings which will tell us which branch to use
|
|
if mappings:
|
|
for key in mappings:
|
|
if re.match(key, hostname):
|
|
return mappings[key]
|
|
if branch is None:
|
|
branch = getattr(settings, 'MODULESTORE_BRANCH', None)
|
|
return branch
|
|
|
|
# leaving this in code structured in closure-friendly format b/c we might eventually cache this (again)
|
|
# using request_cache
|
|
return get_branch_setting()
|