From 2edc7d0816a201805899db0ce710fd339819fdb2 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Fri, 8 Nov 2024 12:41:16 +0500 Subject: [PATCH 1/2] chore: initial commit --- xblocks_contrib/discussion/__init__.py | 2 +- xblocks_contrib/discussion/discussion.py | 367 +++++++++++++++---- xblocks_contrib/discussion/static/README.txt | 18 + 3 files changed, 312 insertions(+), 75 deletions(-) create mode 100644 xblocks_contrib/discussion/static/README.txt diff --git a/xblocks_contrib/discussion/__init__.py b/xblocks_contrib/discussion/__init__.py index 223f080..6baf094 100644 --- a/xblocks_contrib/discussion/__init__.py +++ b/xblocks_contrib/discussion/__init__.py @@ -1,5 +1,5 @@ """ -Init for the DiscussionXBlock. +DiscussionXBlock. """ from .discussion import DiscussionXBlock diff --git a/xblocks_contrib/discussion/discussion.py b/xblocks_contrib/discussion/discussion.py index ed945c6..aa0dfef 100644 --- a/xblocks_contrib/discussion/discussion.py +++ b/xblocks_contrib/discussion/discussion.py @@ -1,104 +1,323 @@ -"""TO-DO: Write a description of what this XBlock is.""" +""" +Discussion XBlock +""" -from importlib.resources import files +import logging +import urllib -from django.utils import translation +from django.contrib.staticfiles.storage import staticfiles_storage +from django.urls import reverse +from django.utils.translation import get_language_bidi +from edx_django_utils.cache import DEFAULT_REQUEST_CACHE +from opaque_keys.edx.keys import CourseKey from web_fragments.fragment import Fragment +from xblock.completable import XBlockCompletionMode from xblock.core import XBlock -from xblock.fields import Integer, Scope +from xblock.fields import UNIQUE_ID, Scope, String from xblock.utils.resources import ResourceLoader +from xblock.utils.studio_editable import StudioEditableXBlockMixin -resource_loader = ResourceLoader(__name__) +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 -# This Xblock is just to test the strucutre of xblocks-contrib -@XBlock.needs("i18n") -class DiscussionXBlock(XBlock): +log = logging.getLogger(__name__) +loader = ResourceLoader(__name__) # pylint: disable=invalid-name + + +def _(text): """ - TO-DO: document what your XBlock does. + A noop underscore function that marks strings for extraction. """ + return text + - # Fields are defined on the class. You can access them in your code as - # self.. +@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 - # TO-DO: delete count, and define your own fields. - count = Integer( - default=0, - scope=Scope.user_state, - help="A simple counter, to show something happening", + 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 all_permissions_for_user_in_course(user, course_id): + """ + Returns all the permissions the user has in the given course. + """ + if not user.is_authenticated: + return {} + + course = modulestore().get_course(course_id) + if course is None: + raise ItemNotFoundError(course_id) + + roles = Role.objects.filter(users=user, course_id=course_id) + role_names = {role.name for role in roles} + + permission_names = set() + for role in roles: + # Intentional n+1 query pattern to get permissions for each role because + # Aurora's query optimizer can't handle the join proplerly on 30M+ row + # tables (EDUCATOR-3374). Fortunately, there are very few forum roles. + for permission in role.permissions.all(): + if not permission_blacked_out(course, role_names, permission.name): + permission_names.add(permission.name) - # Indicates that this XBlock has been extracted from edx-platform. - is_extracted = True + # Prevent a circular import + from openedx.core.djangoapps.django_comment_common.utils import GLOBAL_STAFF_ROLE_PERMISSIONS - def resource_string(self, path): - """Handy helper for getting resources from our kit.""" - return files(__package__).joinpath(path).read_text(encoding="utf-8") + if GlobalStaff().has_user(user): + for permission in GLOBAL_STAFF_ROLE_PERMISSIONS: + permission_names.add(permission) + + return permission_names + + 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 + # """ + assert isinstance(self.course_key, (type(None), CourseKey)) + request_cache_dict = DEFAULT_REQUEST_CACHE.data + cache_key = "django_comment_client.permissions.has_permission.all_permissions.{}.{}".format( + self.django_user.id, self.course_key + ) + if cache_key in request_cache_dict: + all_permissions = request_cache_dict[cache_key] + else: + all_permissions = self.all_permissions_for_user_in_course(self.django_user, self.course_key) + request_cache_dict[cache_key] = all_permissions + + return permission in all_permissions + # return has_permission(self.django_user, permission, self.course_key) - # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ - Create primary view of the DiscussionXBlock, shown to students when viewing courses. + Renders student view for LMS. """ - if context: - pass # TO-DO: do something based on the context. + # 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 = '' - frag = Fragment() - frag.add_content( - resource_loader.render_django_template( - "templates/discussion.html", - { - "count": self.count, - }, - i18n_service=self.runtime.service(self, "i18n"), + 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('{sign_in_label}').format( + sign_in_label=_('sign in'), + url='{}?{}'.format(reverse('signin_user'), qs), + ), + register_link=HTML('{register_label}').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_lms_template('discussion/_discussion_inline.html', context) ) - ) - frag.add_css(self.resource_string("static/css/discussion.css")) - frag.add_javascript(self.resource_string("static/js/src/discussion.js")) - frag.initialize_js("DiscussionXBlock") - return frag + fragment.initialize_js('DiscussionInlineBlock') - # TO-DO: change this handler to perform your own actions. You may need more - # than one handler, or you may not need any handlers at all. - @XBlock.json_handler - def increment_count(self, data, suffix=""): + return fragment + + def author_view(self, context=None): # pylint: disable=unused-argument """ - Increments data. An example handler. + Renders author view for Studio. """ - if suffix: - pass # TO-DO: Use the suffix when storing data. - # Just to show data coming in... - assert data["hello"] == "world" + fragment = Fragment() + # For historic reasons, this template is in the LMS templates folder: + fragment.add_content(self.runtime.service(self, 'mako').render_lms_template( + 'discussion/_discussion_inline_studio.html', + { + 'discussion_id': self.discussion_id, + 'is_visible': self.is_visible, + } + )) + return fragment - self.count += 1 - return {"count": self.count} + def student_view_data(self): + """ + Returns a JSON representation of the student_view of this XBlock. + """ + return {'topic_id': self.discussion_id} - # TO-DO: change this to create the scenarios you'd like to see in the - # workbench while developing your XBlock. - @staticmethod - def workbench_scenarios(): - """Create canned scenario for display in the workbench.""" - return [ - ( - "DiscussionXBlock", - """<_discussion_extracted/> - """, - ), - ( - "Multiple DiscussionXBlock", - """ - <_discussion_extracted/> - <_discussion_extracted/> - <_discussion_extracted/> - - """, - ), - ] + @classmethod + def parse_xml(cls, node, runtime, keys): + """ + Parses OLX into XBlock. - @staticmethod - def get_dummy(): + 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) + + cls._apply_metadata_and_policy(block, node, runtime) + + return block + + @classmethod + def _apply_metadata_and_policy(cls, block, node, runtime): """ - Generate initial i18n with dummy method. + Attempt to load definition XML from "discussion" folder in OLX, than parse it and update block fields """ - return translation.gettext_noop("Dummy") + 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) diff --git a/xblocks_contrib/discussion/static/README.txt b/xblocks_contrib/discussion/static/README.txt new file mode 100644 index 0000000..127da5a --- /dev/null +++ b/xblocks_contrib/discussion/static/README.txt @@ -0,0 +1,18 @@ +This static directory is for files that should be included in your kit as plain +static files. + +You can ask the runtime for a URL that will retrieve these files with: + + url = self.runtime.local_resource_url(self, "static/js/lib.js") + +The default implementation is very strict though, and will not serve files from +the static directory. It will serve files from a directory named "public". +Create a directory alongside this one named "public", and put files there. +Then you can get a url with code like this: + + url = self.runtime.local_resource_url(self, "public/js/lib.js") + +The sample code includes a function you can use to read the content of files +in the static directory, like this: + + frag.add_javascript(self.resource_string("static/js/my_block.js")) From 37c686231d5964e5dbf1ce121387844fb1932b0e Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Fri, 29 Nov 2024 16:28:07 +0500 Subject: [PATCH 2/2] feat: extract templates --- .../static/templates/templates.underscore | 865 ++++++++++++++++++ .../templates/_discussion_inline.html | 47 + .../templates/_discussion_inline_studio.html | 14 + .../templates/_thread_list_template.html | 53 ++ .../templates/_underscore_templates.html | 19 + .../discussion/templates/discussion.html | 7 - 6 files changed, 998 insertions(+), 7 deletions(-) create mode 100644 xblocks_contrib/discussion/static/templates/templates.underscore create mode 100644 xblocks_contrib/discussion/templates/_discussion_inline.html create mode 100644 xblocks_contrib/discussion/templates/_discussion_inline_studio.html create mode 100644 xblocks_contrib/discussion/templates/_thread_list_template.html create mode 100644 xblocks_contrib/discussion/templates/_underscore_templates.html delete mode 100644 xblocks_contrib/discussion/templates/discussion.html diff --git a/xblocks_contrib/discussion/static/templates/templates.underscore b/xblocks_contrib/discussion/static/templates/templates.underscore new file mode 100644 index 0000000..49af9b7 --- /dev/null +++ b/xblocks_contrib/discussion/static/templates/templates.underscore @@ -0,0 +1,865 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xblocks_contrib/discussion/templates/_discussion_inline.html b/xblocks_contrib/discussion/templates/_discussion_inline.html new file mode 100644 index 0000000..dd9ead0 --- /dev/null +++ b/xblocks_contrib/discussion/templates/_discussion_inline.html @@ -0,0 +1,47 @@ +{% load i18n %} + +{% include "_underscore_templates.html" %} +{% include "_thread_list_template.html" %} + +
+ + {% if not user.is_authenticated %} +
+ +
+
+ {% endif %} + +
+

{{ display_name|trans }}

+
+ {% trans "Topic:" %} {{ discussion_category }} + {% if discussion_target %} + / {{ discussion_target }} + {% endif %} +
+
+ + + +
+ + diff --git a/xblocks_contrib/discussion/templates/_discussion_inline_studio.html b/xblocks_contrib/discussion/templates/_discussion_inline_studio.html new file mode 100644 index 0000000..04645f4 --- /dev/null +++ b/xblocks_contrib/discussion/templates/_discussion_inline_studio.html @@ -0,0 +1,14 @@ +{% load i18n %} + +
+

+ + + {% trans "To view live discussions, click Preview or View Live in Unit Settings." %}
+ {% trans "Discussion ID: {discussion_id}" with discussion_id=discussion_id %} + {% if not is_visible %} +
{% trans "The discussion block is disabled for this course as it is not using a compatible discussion provider." %} + {% endif %} +
+

+
diff --git a/xblocks_contrib/discussion/templates/_thread_list_template.html b/xblocks_contrib/discussion/templates/_thread_list_template.html new file mode 100644 index 0000000..154b122 --- /dev/null +++ b/xblocks_contrib/discussion/templates/_thread_list_template.html @@ -0,0 +1,53 @@ +{% load i18n %} + + diff --git a/xblocks_contrib/discussion/templates/_underscore_templates.html b/xblocks_contrib/discussion/templates/_underscore_templates.html new file mode 100644 index 0000000..8ed56e5 --- /dev/null +++ b/xblocks_contrib/discussion/templates/_underscore_templates.html @@ -0,0 +1,19 @@ +{% load static %} + + + + diff --git a/xblocks_contrib/discussion/templates/discussion.html b/xblocks_contrib/discussion/templates/discussion.html deleted file mode 100644 index c54902b..0000000 --- a/xblocks_contrib/discussion/templates/discussion.html +++ /dev/null @@ -1,7 +0,0 @@ -{% load i18n %} - -
-

- DiscussionXBlock: {% trans "count is now" %} {{ count }} {% trans "click me to increment." %} -

-