The Webpack configuration file for built-in XBlock JS used to be generated at build time and git-ignored. It lived at common/static/xmodule/webpack.xmodule.config.js. It was generated because the JS that it referred to was also generated at build-time, and the filenames of those JS modules were not static. Now that its contents have been made entirely static [1], there is no reason we need to continue generating this Webpack configuration file. So, we check it into edx-platform under the name ./webpack.builtinblocks.config.js. We choose to put it in the repo's root directory because the paths contained in the config file are relative to the repo's root. This allows us to behead both the xmodule/static_content.py (`xmodule_assets`) script andthe `process_xmodule_assets` paver task, a major step in removing the need for Python in the edx-platform asset build [2]. It also allows us to delete the `HTMLSnippet` class and all associated attributes, which were exclusively used by xmodule/static_content.py.. We leave `xmodule_assets` and `process_xmodule_assets` in as stubs for now in order to avoid breaking external code (like Tutor) which calls Paver; the entire pavelib/assets.py function will be eventually removed soon anyway [3]. Further, to avoid extraneous refactoring, we keep one method of `HTMLSnippet` around on a few of its former subclasses: `get_html`. This method was originally part of the XModule framework; now, it is left over on a few classes as a simple internal helper method. References: 1. https://github.com/openedx/edx-platform/pull/32480 2. https://github.com/openedx/edx-platform/issues/31800 3. https://github.com/openedx/edx-platform/issues/31895 Part of: https://github.com/openedx/edx-platform/issues/32481
277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""
|
|
Discussion XBlock
|
|
"""
|
|
|
|
import logging
|
|
import urllib
|
|
|
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
|
from django.urls import reverse
|
|
from django.utils.translation import get_language_bidi
|
|
from web_fragments.fragment import Fragment
|
|
from xblock.completable import XBlockCompletionMode
|
|
from xblock.core import XBlock
|
|
from xblock.fields import UNIQUE_ID, Scope, String
|
|
from xblockutils.resources import ResourceLoader
|
|
from xblockutils.studio_editable import StudioEditableXBlockMixin
|
|
|
|
from lms.djangoapps.discussion.django_comment_client.permissions import has_permission
|
|
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider
|
|
from openedx.core.djangolib.markup import HTML, Text
|
|
from openedx.core.lib.xblock_utils import get_css_dependencies, get_js_dependencies
|
|
from xmodule.xml_block import XmlMixin
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
loader = ResourceLoader(__name__) # pylint: disable=invalid-name
|
|
|
|
|
|
def _(text):
|
|
"""
|
|
A noop underscore function that marks strings for extraction.
|
|
"""
|
|
return text
|
|
|
|
|
|
@XBlock.needs('user') # pylint: disable=abstract-method
|
|
@XBlock.needs('i18n')
|
|
@XBlock.needs('mako')
|
|
class DiscussionXBlock(XBlock, StudioEditableXBlockMixin, XmlMixin): # lint-amnesty, pylint: disable=abstract-method
|
|
"""
|
|
Provides a discussion forum that is inline with other content in the courseware.
|
|
"""
|
|
completion_mode = XBlockCompletionMode.EXCLUDED
|
|
|
|
discussion_id = String(scope=Scope.settings, default=UNIQUE_ID)
|
|
display_name = String(
|
|
display_name=_("Display Name"),
|
|
help=_("The display name for this component."),
|
|
default="Discussion",
|
|
scope=Scope.settings
|
|
)
|
|
discussion_category = String(
|
|
display_name=_("Category"),
|
|
default=_("Week 1"),
|
|
help=_(
|
|
"A category name for the discussion. "
|
|
"This name appears in the left pane of the discussion forum for the course."
|
|
),
|
|
scope=Scope.settings
|
|
)
|
|
discussion_target = String(
|
|
display_name=_("Subcategory"),
|
|
default="Topic-Level Student-Visible Label",
|
|
help=_(
|
|
"A subcategory name for the discussion. "
|
|
"This name appears in the left pane of the discussion forum for the course."
|
|
),
|
|
scope=Scope.settings
|
|
)
|
|
sort_key = String(scope=Scope.settings)
|
|
|
|
editable_fields = ["display_name", "discussion_category", "discussion_target"]
|
|
|
|
has_author_view = True # Tells Studio to use author_view
|
|
|
|
@property
|
|
def course_key(self):
|
|
return getattr(self.scope_ids.usage_id, 'course_key', None)
|
|
|
|
@property
|
|
def is_visible(self):
|
|
"""
|
|
Discussion Xblock does not support new OPEN_EDX provider
|
|
"""
|
|
provider = DiscussionsConfiguration.get(self.course_key)
|
|
return provider.provider_type == Provider.LEGACY
|
|
|
|
@property
|
|
def django_user(self):
|
|
"""
|
|
Returns django user associated with user currently interacting
|
|
with the XBlock.
|
|
"""
|
|
user_service = self.runtime.service(self, 'user')
|
|
if not user_service:
|
|
return None
|
|
return user_service._django_user # pylint: disable=protected-access
|
|
|
|
@staticmethod
|
|
def vendor_js_dependencies():
|
|
"""
|
|
Returns list of vendor JS files that this XBlock depends on.
|
|
|
|
The helper function that it uses to obtain the list of vendor JS files
|
|
works in conjunction with the Django pipeline to ensure that in development mode
|
|
the files are loaded individually, but in production just the single bundle is loaded.
|
|
"""
|
|
return get_js_dependencies('discussion_vendor')
|
|
|
|
@staticmethod
|
|
def js_dependencies():
|
|
"""
|
|
Returns list of JS files that this XBlock depends on.
|
|
|
|
The helper function that it uses to obtain the list of JS files
|
|
works in conjunction with the Django pipeline to ensure that in development mode
|
|
the files are loaded individually, but in production just the single bundle is loaded.
|
|
"""
|
|
return get_js_dependencies('discussion')
|
|
|
|
@staticmethod
|
|
def css_dependencies():
|
|
"""
|
|
Returns list of CSS files that this XBlock depends on.
|
|
|
|
The helper function that it uses to obtain the list of CSS files
|
|
works in conjunction with the Django pipeline to ensure that in development mode
|
|
the files are loaded individually, but in production just the single bundle is loaded.
|
|
"""
|
|
if get_language_bidi():
|
|
return get_css_dependencies('style-inline-discussion-rtl')
|
|
else:
|
|
return get_css_dependencies('style-inline-discussion')
|
|
|
|
def add_resource_urls(self, fragment):
|
|
"""
|
|
Adds URLs for JS and CSS resources that this XBlock depends on to `fragment`.
|
|
"""
|
|
# Head dependencies
|
|
for vendor_js_file in self.vendor_js_dependencies():
|
|
fragment.add_resource_url(staticfiles_storage.url(vendor_js_file), "application/javascript", "head")
|
|
|
|
for css_file in self.css_dependencies():
|
|
fragment.add_css_url(staticfiles_storage.url(css_file))
|
|
|
|
# Body dependencies
|
|
for js_file in self.js_dependencies():
|
|
fragment.add_javascript_url(staticfiles_storage.url(js_file))
|
|
|
|
def has_permission(self, permission):
|
|
"""
|
|
Encapsulates lms specific functionality, as `has_permission` is not
|
|
importable outside of lms context, namely in tests.
|
|
|
|
:param user:
|
|
:param str permission: Permission
|
|
:rtype: bool
|
|
"""
|
|
return has_permission(self.django_user, permission, self.course_key)
|
|
|
|
def student_view(self, context=None):
|
|
"""
|
|
Renders student view for LMS.
|
|
"""
|
|
# to prevent a circular import issue
|
|
import lms.djangoapps.discussion.django_comment_client.utils as utils
|
|
|
|
fragment = Fragment()
|
|
|
|
if not self.is_visible:
|
|
return fragment
|
|
|
|
self.add_resource_urls(fragment)
|
|
login_msg = ''
|
|
|
|
if not self.django_user.is_authenticated:
|
|
qs = urllib.parse.urlencode({
|
|
'course_id': self.course_key,
|
|
'enrollment_action': 'enroll',
|
|
'email_opt_in': False,
|
|
})
|
|
login_msg = Text(_("You are not signed in. To view the discussion content, {sign_in_link} or "
|
|
"{register_link}, and enroll in this course.")).format(
|
|
sign_in_link=HTML('<a href="{url}">{sign_in_label}</a>').format(
|
|
sign_in_label=_('sign in'),
|
|
url='{}?{}'.format(reverse('signin_user'), qs),
|
|
),
|
|
register_link=HTML('<a href="/{url}">{register_label}</a>').format(
|
|
register_label=_('register'),
|
|
url='{}?{}'.format(reverse('register_user'), qs),
|
|
),
|
|
)
|
|
if utils.is_discussion_enabled(self.course_key):
|
|
context = {
|
|
'discussion_id': self.discussion_id,
|
|
'display_name': self.display_name if self.display_name else _("Discussion"),
|
|
'user': self.django_user,
|
|
'course_id': self.course_key,
|
|
'discussion_category': self.discussion_category,
|
|
'discussion_target': self.discussion_target,
|
|
'can_create_thread': self.has_permission("create_thread"),
|
|
'can_create_comment': self.has_permission("create_comment"),
|
|
'can_create_subcomment': self.has_permission("create_sub_comment"),
|
|
'login_msg': login_msg,
|
|
}
|
|
fragment.add_content(
|
|
self.runtime.service(self, 'mako').render_template('discussion/_discussion_inline.html', context)
|
|
)
|
|
|
|
fragment.initialize_js('DiscussionInlineBlock')
|
|
|
|
return fragment
|
|
|
|
def author_view(self, context=None): # pylint: disable=unused-argument
|
|
"""
|
|
Renders author view for Studio.
|
|
"""
|
|
fragment = Fragment()
|
|
fragment.add_content(self.runtime.service(self, 'mako').render_template(
|
|
'discussion/_discussion_inline_studio.html',
|
|
{
|
|
'discussion_id': self.discussion_id,
|
|
'is_visible': self.is_visible,
|
|
}
|
|
))
|
|
return fragment
|
|
|
|
def student_view_data(self):
|
|
"""
|
|
Returns a JSON representation of the student_view of this XBlock.
|
|
"""
|
|
return {'topic_id': self.discussion_id}
|
|
|
|
@classmethod
|
|
def parse_xml(cls, node, runtime, keys, id_generator):
|
|
"""
|
|
Parses OLX into XBlock.
|
|
|
|
This method is overridden here to allow parsing legacy OLX, coming from discussion XModule.
|
|
XBlock stores all the associated data, fields and children in a XML element inlined into vertical XML file
|
|
XModule stored only minimal data on the element included into vertical XML and used a dedicated "discussion"
|
|
folder in OLX to store fields and children. Also, some info was put into "policy.json" file.
|
|
|
|
If no external data sources are found (file in "discussion" folder), it is exactly equivalent to base method
|
|
XBlock.parse_xml. Otherwise this method parses file in "discussion" folder (known as definition_xml), applies
|
|
policy.json and updates fields accordingly.
|
|
"""
|
|
block = super().parse_xml(node, runtime, keys, id_generator)
|
|
|
|
cls._apply_metadata_and_policy(block, node, runtime)
|
|
|
|
return block
|
|
|
|
@classmethod
|
|
def _apply_metadata_and_policy(cls, block, node, runtime):
|
|
"""
|
|
Attempt to load definition XML from "discussion" folder in OLX, than parse it and update block fields
|
|
"""
|
|
if node.get('url_name') is None:
|
|
return # Newer/XBlock XML format - no need to load an additional file.
|
|
try:
|
|
definition_xml, _ = cls.load_definition_xml(node, runtime, block.scope_ids.def_id)
|
|
except Exception as err: # pylint: disable=broad-except
|
|
log.info(
|
|
"Exception %s when trying to load definition xml for block %s - assuming XBlock export format",
|
|
err,
|
|
block
|
|
)
|
|
return
|
|
|
|
metadata = cls.load_metadata(definition_xml)
|
|
cls.apply_policy(metadata, runtime.get_policy(block.scope_ids.usage_id))
|
|
|
|
for field_name, value in metadata.items():
|
|
if field_name in block.fields:
|
|
setattr(block, field_name, value)
|