Skip to content

Commit eec1401

Browse files
authored
Merge pull request #536 from open-craft/jill/fal-4032-library-context
Allow lti_consumer blocks to be used in Libraries v2 context [FC-0076]
2 parents e25638a + 7cb5f1b commit eec1401

File tree

7 files changed

+223
-52
lines changed

7 files changed

+223
-52
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ Please See the `releases tab <https://github.com/openedx/xblock-lti-consumer/rel
1616
Unreleased
1717
~~~~~~~~~~
1818

19+
9.13.3 - 2025-03-12
20+
-------------------
21+
* Allows LTI Consumer blocks to be used in Libraries v2 learning context
22+
1923
9.13.2 - 2025-01-21
2024
-------------------
2125
* Fix Data too long for column 'resource_id'. Increase column size to 255.

lti_consumer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
from .apps import LTIConsumerApp
55
from .lti_xblock import LtiConsumerXBlock
66

7-
__version__ = '9.13.2'
7+
__version__ = '9.13.3'

lti_consumer/lti_xblock.py

Lines changed: 70 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -714,8 +714,9 @@ def validate(self):
714714
# This validation is just for the Unit page in Studio; we don't want to block users from saving
715715
# a new LTI ID before they've added it to advanced settings, but we do want to warn them about it.
716716
# If we put this check in validate_field_data(), the settings editor wouldn't let them save changes.
717-
if self.lti_version == "lti_1p1" and self.lti_id:
718-
lti_passport_ids = [lti_passport.split(':')[0].strip() for lti_passport in self.course.lti_passports]
717+
course = self.course
718+
if course and self.lti_version == "lti_1p1" and self.lti_id:
719+
lti_passport_ids = [lti_passport.split(':')[0].strip() for lti_passport in course.lti_passports]
719720
if self.lti_id.strip() not in lti_passport_ids:
720721
validation.add(ValidationMessage(ValidationMessage.WARNING, str(
721722
_("The specified LTI ID is not configured in this course's Advanced Settings.")
@@ -846,24 +847,37 @@ def role(self):
846847
Get system user role.
847848
"""
848849
user = self.runtime.service(self, 'user').get_current_user()
849-
if not user.opt_attrs["edx-platform.is_authenticated"]:
850+
if not user.opt_attrs.get("edx-platform.is_authenticated"):
850851
raise LtiError(self.ugettext("Could not get user data for current request"))
851852

852853
return user.opt_attrs.get('edx-platform.user_role', 'student')
853854

855+
@property
856+
def user_is_staff(self):
857+
"""
858+
Get system user's is_staff flag.
859+
"""
860+
user = self.runtime.service(self, "user").get_current_user()
861+
return (
862+
user.opt_attrs.get("edx-platform.is_authenticated", False) and
863+
user.opt_attrs.get("edx-platform.user_is_staff", False)
864+
)
865+
854866
@property
855867
def course(self):
856868
"""
857869
Return course by course id.
858870
"""
859-
return self.runtime.modulestore.get_course(self.scope_ids.usage_id.context_key)
871+
return compat.get_course_by_id(self.scope_ids.usage_id.context_key)
860872

861873
@property
862874
def lti_provider_key_secret(self):
863875
"""
864876
Obtains client_key and client_secret credentials from current course.
865877
"""
866-
for lti_passport in self.course.lti_passports:
878+
course = self.course
879+
lti_passports = course.lti_passports if course else []
880+
for lti_passport in lti_passports:
867881
try:
868882
# NOTE While unpacking the lti_passport by using ":" as delimiter, first item will be lti_id,
869883
# last item will be client_secret and the rest are considered as client_key.
@@ -1073,7 +1087,7 @@ def prefixed_custom_parameters(self):
10731087

10741088
custom_parameters['custom_component_display_name'] = str(self.display_name)
10751089

1076-
if self.due:
1090+
if getattr(self, 'due', None):
10771091
custom_parameters.update({
10781092
'custom_component_due_date': self.due.strftime('%Y-%m-%d %H:%M:%S')
10791093
})
@@ -1124,7 +1138,7 @@ def extract_real_user_data(self):
11241138
"""
11251139
user = self.runtime.service(self, 'user').get_current_user()
11261140

1127-
if not user.opt_attrs["edx-platform.is_authenticated"]:
1141+
if not user.opt_attrs.get("edx-platform.is_authenticated"):
11281142
raise LtiError(self.ugettext("Could not get user data for current request"))
11291143

11301144
user_data = {
@@ -1144,6 +1158,38 @@ def extract_real_user_data(self):
11441158

11451159
return user_data
11461160

1161+
def _add_author_view(self, context, loader, fragment):
1162+
"""
1163+
Adds the "author view" content to the given fragment.
1164+
1165+
Assumes that the CSS/JS will be added by the caller.
1166+
"""
1167+
# Runtime import since this will only run in the
1168+
# Open edX LMS/Studio environments.
1169+
# pylint: disable=import-outside-toplevel
1170+
from lti_consumer.api import get_lti_1p3_launch_info
1171+
1172+
if not context:
1173+
context = {}
1174+
1175+
# Retrieve LTI 1.3 Launch information
1176+
launch_data = self.get_lti_1p3_launch_data()
1177+
context.update(
1178+
get_lti_1p3_launch_info(
1179+
launch_data,
1180+
)
1181+
)
1182+
1183+
# Render template
1184+
fragment.add_content(
1185+
loader.render_django_template(
1186+
'/templates/html/lti_1p3_studio.html',
1187+
context,
1188+
i18n_service=self.runtime.service(self, 'i18n')
1189+
),
1190+
)
1191+
return fragment
1192+
11471193
def studio_view(self, context):
11481194
"""
11491195
Get Studio View fragment
@@ -1167,29 +1213,11 @@ def author_view(self, context):
11671213
if self.lti_version == "lti_1p1":
11681214
return self.student_view(context)
11691215

1170-
# Runtime import since this will only run in the
1171-
# Open edX LMS/Studio environments.
1172-
# pylint: disable=import-outside-toplevel
1173-
from lti_consumer.api import get_lti_1p3_launch_info
1174-
1175-
# Retrieve LTI 1.3 Launch information
1176-
launch_data = self.get_lti_1p3_launch_data()
1177-
context.update(
1178-
get_lti_1p3_launch_info(
1179-
launch_data,
1180-
)
1181-
)
1182-
11831216
# Render template
11841217
fragment = Fragment()
11851218
loader = ResourceLoader(__name__)
1186-
fragment.add_content(
1187-
loader.render_django_template(
1188-
'/templates/html/lti_1p3_studio.html',
1189-
context,
1190-
i18n_service=self.runtime.service(self, 'i18n')
1191-
),
1192-
)
1219+
self._add_author_view(context, loader, fragment)
1220+
11931221
fragment.add_css(loader.load_unicode('static/css/student.css'))
11941222
fragment.add_javascript(loader.load_unicode('static/js/xblock_lti_consumer.js'))
11951223
statici18n_js_url = self._get_statici18n_js_url()
@@ -1215,8 +1243,16 @@ def student_view(self, context):
12151243
"""
12161244
fragment = Fragment()
12171245
loader = ResourceLoader(__name__)
1246+
context = context or {}
12181247
context.update(self._get_context_for_template())
1248+
1249+
# Prepend the author view for LTI1.3 when rendering student view to staff users in Studio.
1250+
# This is needed so course staff can see the author view parameters when configuring within Libraries v2
1251+
if settings.SERVICE_VARIANT != 'lms' and self.lti_version == "lti_1p3" and self.user_is_staff:
1252+
self._add_author_view(context, loader, fragment)
1253+
12191254
fragment.add_content(loader.render_mako_template('/templates/html/student.html', context))
1255+
12201256
fragment.add_css(loader.load_unicode('static/css/student.css'))
12211257
fragment.add_javascript(loader.load_unicode('static/js/xblock_lti_consumer.js'))
12221258
statici18n_js_url = self._get_statici18n_js_url()
@@ -1285,10 +1321,11 @@ def lti_launch_handler(self, request, suffix=''): # pylint: disable=unused-argu
12851321
person_name_full=full_name,
12861322
)
12871323

1324+
course = self.course
12881325
lti_consumer.set_context_data(
12891326
self.context_id,
1290-
self.course.display_name_with_default,
1291-
self.course.display_org_with_default
1327+
course.display_name_with_default if course else "",
1328+
course.display_org_with_default if course else "",
12921329
)
12931330

12941331
if self.has_score:
@@ -1659,12 +1696,10 @@ def get_context_title(self):
16591696
Return the title attribute of the context_claim for LTI 1.3 launches. This information is included in the
16601697
launch_data query or form parameter of the LTI 1.3 third-party login initiation request.
16611698
"""
1662-
course_key = self.scope_ids.usage_id.context_key
1663-
course = compat.get_course_by_id(course_key)
1664-
1699+
course = self.course
16651700
return " - ".join([
1666-
course.display_name_with_default,
1667-
course.display_org_with_default
1701+
course.display_name_with_default if course else "",
1702+
course.display_org_with_default if course else "",
16681703
])
16691704

16701705
def _get_lti_block_launch_handler(self):
@@ -1732,7 +1767,7 @@ def _get_context_for_template(self):
17321767
'launch_url': launch_url.strip(),
17331768
'lti_1p3_launch_url': lti_1p3_launch_url,
17341769
'element_id': self.scope_ids.usage_id.html_id(),
1735-
'element_class': self.category,
1770+
'element_class': getattr(self, 'category', ''),
17361771
'launch_target': self.launch_target,
17371772
'display_name': self.display_name,
17381773
'form_url': lti_block_launch_handler,

lti_consumer/plugin/compat.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,14 @@ def load_enough_xblock(location): # pragma: nocover
8989
"""
9090
# pylint: disable=import-error,import-outside-toplevel
9191
from xmodule.modulestore.django import modulestore
92+
from openedx.core.djangoapps.xblock import api as xblock_api
9293

93-
# Retrieve block from modulestore
94-
return modulestore().get_item(location)
94+
# Retrieve course block from modulestore
95+
if isinstance(location.context_key, CourseKey):
96+
return modulestore().get_item(location)
97+
# Retrieve library block from the XBlock API
98+
else:
99+
return xblock_api.load_block(location, None)
95100

96101

97102
def load_block_as_user(location): # pragma: nocover
@@ -223,17 +228,14 @@ def get_course_by_id(course_key): # pragma: nocover
223228
"""
224229
Import and run `get_course_by_id` from LMS
225230
226-
TODO: Once the LMS has fully switched over to this new path [1],
227-
we can remove the legacy (LMS) import support here.
228-
229-
- [1] https://github.com/openedx/edx-platform/pull/27289
231+
Returns None if the provided key is not a CourseKey,
232+
e.g the block is used in a library learning context.
230233
"""
231-
# pylint: disable=import-outside-toplevel
232-
try:
233-
from openedx.core.lib.courses import get_course_by_id as lms_get_course_by_id
234-
except ImportError:
235-
from lms.djangoapps.courseware.courses import get_course_by_id as lms_get_course_by_id
236-
return lms_get_course_by_id(course_key)
234+
# pylint: disable=import-error,import-outside-toplevel
235+
from openedx.core.lib.courses import get_course_by_id as lms_get_course_by_id
236+
if isinstance(course_key, CourseKey):
237+
return lms_get_course_by_id(course_key)
238+
return None
237239

238240

239241
def user_course_access(*args, **kwargs): # pragma: nocover

lti_consumer/tests/unit/plugin/test_views.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ def setUp(self):
180180
self.compat.get_user_role.return_value = "student"
181181
self.compat.get_external_id_for_user.return_value = "12345"
182182

183+
block_compat_patcher = patch("lti_consumer.lti_xblock.compat")
184+
self.addCleanup(block_compat_patcher.stop)
185+
block_compat = block_compat_patcher.start()
186+
block_compat.get_course_by_id.return_value = course
187+
block_compat.get_user_role.return_value = "student"
188+
block_compat.get_external_id_for_user.return_value = "12345"
189+
183190
model_compat_patcher = patch("lti_consumer.models.compat")
184191
self.addCleanup(model_compat_patcher.stop)
185192
model_compat = model_compat_patcher.start()

0 commit comments

Comments
 (0)