From 21ddd04165278937f4d37d948d4da611ccebc18a Mon Sep 17 00:00:00 2001 From: Markos Gogoulos Date: Thu, 19 Feb 2026 13:00:41 +0200 Subject: [PATCH] a --- .../filter/mediacms/classes/hook_listener.php | 49 +++++++ .../filter/mediacms/db/hooks.php | 13 ++ .../mediacms/lang/en/filter_mediacms.php | 10 ++ .../filter/mediacms/launch.php | 57 +------- .../mediacms-moodle/filter/mediacms/lib.php | 72 ++++++++++ .../filter/mediacms/locallib.php | 67 +++++++++ .../filter/mediacms/lti_launch.php | 78 +++++++++++ .../filter/mediacms/my_media.php | 38 +++++ .../filter/mediacms/settings.php | 12 ++ lti/handlers.py | 130 ++++++++++++++++++ lti/views.py | 18 ++- 11 files changed, 486 insertions(+), 58 deletions(-) create mode 100644 lms-plugins/mediacms-moodle/filter/mediacms/classes/hook_listener.php create mode 100644 lms-plugins/mediacms-moodle/filter/mediacms/db/hooks.php create mode 100644 lms-plugins/mediacms-moodle/filter/mediacms/lib.php create mode 100644 lms-plugins/mediacms-moodle/filter/mediacms/locallib.php create mode 100644 lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php create mode 100644 lms-plugins/mediacms-moodle/filter/mediacms/my_media.php diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/classes/hook_listener.php b/lms-plugins/mediacms-moodle/filter/mediacms/classes/hook_listener.php new file mode 100644 index 00000000..e84226a6 --- /dev/null +++ b/lms-plugins/mediacms-moodle/filter/mediacms/classes/hook_listener.php @@ -0,0 +1,49 @@ +get_primarynav(); + if ($primarynav === null) { + return; + } + $primarynav->add_node($node); + } +} diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/db/hooks.php b/lms-plugins/mediacms-moodle/filter/mediacms/db/hooks.php new file mode 100644 index 00000000..e273339b --- /dev/null +++ b/lms-plugins/mediacms-moodle/filter/mediacms/db/hooks.php @@ -0,0 +1,13 @@ +dirroot . '/mod/lti/lib.php'); require_once($CFG->dirroot . '/mod/lti/locallib.php'); require_once($CFG->dirroot . '/course/modlib.php'); +require_once(__DIR__ . '/locallib.php'); global $SITE, $DB, $PAGE, $OUTPUT, $CFG; -/** - * Find first LTI activity for the MediaCMS tool, or create dummy if none exists - */ -function filter_mediacms_get_dummy_activity($courseid, $typeid) { - global $DB; - - // Find any existing LTI activity with this tool - $sql = "SELECT cm.id - FROM {course_modules} cm - JOIN {modules} m ON m.id = cm.module - JOIN {lti} lti ON lti.id = cm.instance - WHERE cm.course = :courseid - AND m.name = 'lti' - AND lti.typeid = :typeid - AND cm.deletioninprogress = 0 - LIMIT 1"; - - $existing = $DB->get_record_sql($sql, ['courseid' => $courseid, 'typeid' => $typeid]); - if ($existing) { - // Ensure it's accessible (fix if created with visible=0) - $cm = get_coursemodule_from_id('lti', $existing->id, 0, false, IGNORE_MISSING); - if ($cm && !$cm->visible) { - set_coursemodule_visible($existing->id, 1); - } - return $existing->id; - } - - // No existing activity - create dummy in stealth mode (accessible but completely hidden) - $moduleinfo = new stdClass(); - $moduleinfo->course = $courseid; - $moduleinfo->module = $DB->get_field('modules', 'id', ['name' => 'lti']); - $moduleinfo->modulename = 'lti'; - $moduleinfo->section = 0; - $moduleinfo->visible = 1; // Accessible to all - $moduleinfo->visibleoncoursepage = 0; // Hidden from course page - $moduleinfo->availability = null; // No restrictions - $moduleinfo->showdescription = 0; // Don't show description - $moduleinfo->name = 'MediaCMS Filter Launcher'; - $moduleinfo->intro = ''; // Empty intro - $moduleinfo->introformat = FORMAT_HTML; - $moduleinfo->typeid = $typeid; - $moduleinfo->instructorchoiceacceptgrades = 0; - $moduleinfo->grade = 0; - $moduleinfo->instructorchoicesendname = 1; - $moduleinfo->instructorchoicesendemailaddr = 1; - $moduleinfo->launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS; - $moduleinfo->instructorcustomparameters = ''; - - $result = add_moduleinfo($moduleinfo, get_course($courseid)); - - // Additionally hide from activity navigation by setting it as orphaned - set_coursemodule_visible($result->coursemodule, 1, 0); // visible but not on course page - - return $result->coursemodule; -} - require_login(); $mediatoken = required_param('token', PARAM_ALPHANUMEXT); diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/lib.php b/lms-plugins/mediacms-moodle/filter/mediacms/lib.php new file mode 100644 index 00000000..d4bd789d --- /dev/null +++ b/lms-plugins/mediacms-moodle/filter/mediacms/lib.php @@ -0,0 +1,72 @@ +showinflatnavigation = true; + $navigation->add_node($node); +} + +/** + * Add My Media to the user account / settings navigation. + * Fires when placement is set to 'user'. + * In Moodle 4.x Boost this section is reachable via avatar → Preferences. + */ +function filter_mediacms_extend_navigation_user_settings( + navigation_node $navigation, + stdClass $user, + context_user $usercontext, + stdClass $course, + context_course $coursecontext +): void { + $placement = get_config('filter_mediacms', 'mymedia_placement'); + if ($placement !== 'user') { + return; + } + + if (!isloggedin() || isguestuser()) { + return; + } + + $url = new moodle_url('/filter/mediacms/my_media.php'); + $navigation->add( + get_string('mymedia', 'filter_mediacms'), + $url, + navigation_node::TYPE_SETTING, + null, + 'mediacms_mymedia', + new pix_icon('i/media', '') + ); +} diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/locallib.php b/lms-plugins/mediacms-moodle/filter/mediacms/locallib.php new file mode 100644 index 00000000..443fd52e --- /dev/null +++ b/lms-plugins/mediacms-moodle/filter/mediacms/locallib.php @@ -0,0 +1,67 @@ +get_record_sql($sql, ['courseid' => $courseid, 'typeid' => $typeid]); + if ($existing) { + $cm = get_coursemodule_from_id('lti', $existing->id, 0, false, IGNORE_MISSING); + if ($cm && !$cm->visible) { + set_coursemodule_visible($existing->id, 1); + } + return $existing->id; + } + + // Create a stealth dummy activity (accessible but hidden from the course page). + $moduleinfo = new stdClass(); + $moduleinfo->course = $courseid; + $moduleinfo->module = $DB->get_field('modules', 'id', ['name' => 'lti']); + $moduleinfo->modulename = 'lti'; + $moduleinfo->section = 0; + $moduleinfo->visible = 1; + $moduleinfo->visibleoncoursepage = 0; + $moduleinfo->availability = null; + $moduleinfo->showdescription = 0; + $moduleinfo->name = 'MediaCMS Filter Launcher'; + $moduleinfo->intro = ''; + $moduleinfo->introformat = FORMAT_HTML; + $moduleinfo->typeid = $typeid; + $moduleinfo->instructorchoiceacceptgrades = 0; + $moduleinfo->grade = 0; + $moduleinfo->instructorchoicesendname = 1; + $moduleinfo->instructorchoicesendemailaddr = 1; + $moduleinfo->launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS; + $moduleinfo->instructorcustomparameters = ''; + + $result = add_moduleinfo($moduleinfo, get_course($courseid)); + set_coursemodule_visible($result->coursemodule, 1, 0); + + return $result->coursemodule; +} diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php b/lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php new file mode 100644 index 00000000..3d15d014 --- /dev/null +++ b/lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php @@ -0,0 +1,78 @@ +dirroot . '/mod/lti/lib.php'); +require_once($CFG->dirroot . '/mod/lti/locallib.php'); +require_once($CFG->dirroot . '/course/modlib.php'); +require_once(__DIR__ . '/locallib.php'); + +global $SITE, $DB, $CFG, $USER; + +require_login(); + +$mediacmsurl = get_config('filter_mediacms', 'mediacmsurl'); +$ltitoolid = get_config('filter_mediacms', 'ltitoolid'); + +if (empty($mediacmsurl) || empty($ltitoolid)) { + throw new moodle_exception('notconfigured', 'filter_mediacms'); +} + +$type = $DB->get_record('lti_types', ['id' => $ltitoolid]); +if (!$type) { + throw new moodle_exception('ltitoolnotfound', 'filter_mediacms'); +} + +// Build custom_publishdata: all courses the user is enrolled in + role. +$enrolled_courses = enrol_get_users_courses($USER->id, true, ['id', 'shortname', 'fullname']); + +$publish_data = []; +foreach ($enrolled_courses as $enrolled_course) { + if ((int)$enrolled_course->id === SITEID) { + continue; + } + + $course_context = context_course::instance($enrolled_course->id); + $roles = get_user_roles($course_context, $USER->id, false); + + $role_shortname = 'student'; + if (!empty($roles)) { + $role = reset($roles); + $role_shortname = $role->shortname; + } + + $publish_data[] = [ + 'id' => (int)$enrolled_course->id, + 'shortname' => $enrolled_course->shortname, + 'fullname' => $enrolled_course->fullname, + 'role' => $role_shortname, + ]; +} + +$publishdata_b64 = base64_encode(json_encode($publish_data)); + +try { + $dummy_cmid = filter_mediacms_get_dummy_activity(SITEID, $type->id); +} catch (Exception $e) { + throw new moodle_exception('cannotcreatedummyactivity', 'filter_mediacms'); +} + +$cm = get_coursemodule_from_id('lti', $dummy_cmid, 0, false, MUST_EXIST); +$instance = $DB->get_record('lti', ['id' => $cm->instance], '*', MUST_EXIST); + +$instance->instructorcustomparameters = 'publishdata=' . $publishdata_b64; +$instance->name = 'MediaCMS My Media'; + +$typeconfig = lti_get_type_type_config($type->id); +$content = lti_initiate_login($SITE->id, $dummy_cmid, $instance, $typeconfig, null, $instance->name); + +echo $content; diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/my_media.php b/lms-plugins/mediacms-moodle/filter/mediacms/my_media.php new file mode 100644 index 00000000..6eaa8777 --- /dev/null +++ b/lms-plugins/mediacms-moodle/filter/mediacms/my_media.php @@ -0,0 +1,38 @@ +set_context($context); +$PAGE->set_url(new moodle_url('/filter/mediacms/my_media.php')); +$PAGE->set_course($SITE); +$PAGE->set_pagelayout('mydashboard'); +$PAGE->set_title(get_string('mymedia', 'filter_mediacms')); +$PAGE->set_heading(get_string('mymedia', 'filter_mediacms')); + +echo $OUTPUT->header(); + +$attr = [ + 'id' => 'contentframe', + 'src' => (new moodle_url('/filter/mediacms/lti_launch.php'))->out(false), + 'width' => '100%', + 'height' => '600px', + 'allowfullscreen' => 'true', + 'allow' => 'autoplay *; fullscreen *; encrypted-media *; camera *; microphone *;', + 'style' => 'border:none;display:block;', +]; +echo html_writer::tag('iframe', '', $attr); + +echo $OUTPUT->footer(); diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/settings.php b/lms-plugins/mediacms-moodle/filter/mediacms/settings.php index c3a169af..400b4b74 100644 --- a/lms-plugins/mediacms-moodle/filter/mediacms/settings.php +++ b/lms-plugins/mediacms-moodle/filter/mediacms/settings.php @@ -39,4 +39,16 @@ if ($ADMIN->fulltree) { 0, $ltioptions )); + + // My Media link placement. + $settings->add(new admin_setting_configselect( + 'filter_mediacms/mymedia_placement', + get_string('mymedia_placement', 'filter_mediacms'), + get_string('mymedia_placement_desc', 'filter_mediacms'), + 'top', + [ + 'top' => get_string('mymedia_placement_top', 'filter_mediacms'), + 'user' => get_string('mymedia_placement_user', 'filter_mediacms'), + ] + )); } diff --git a/lti/handlers.py b/lti/handlers.py index 30b70cdc..da3a29a4 100644 --- a/lti/handlers.py +++ b/lti/handlers.py @@ -8,7 +8,9 @@ Provides functions to: - Create and manage LTI sessions """ +import base64 import hashlib +import json import logging from allauth.account.models import EmailAddress @@ -200,6 +202,134 @@ def provision_lti_context(platform, claims, resource_link_id): return category, rbac_group, resource_link +# Moodle role shortnames → MediaCMS group roles. +_MOODLE_ROLE_TO_GROUP_ROLE = { + 'student': 'member', + 'guest': 'member', + 'teacher': 'manager', + 'editingteacher': 'manager', + 'manager': 'manager', + 'coursecreator': 'manager', + 'ta': 'contributor', +} + + +def provision_lti_bulk_contexts(platform, user, publish_data_raw): + """ + Bulk-provision categories, groups, and memberships for every course the + user is enrolled in, as reported by the LMS via the custom_publishdata + parameter. + + Called on My Media launches where there is no specific course context. + Skips the Moodle site course (ID 1). + + Args: + platform: LTIPlatform instance + user: User instance + publish_data_raw: base64-encoded JSON string — list of dicts with + keys: id, shortname, fullname, role + """ + try: + # Restore any stripped base64 padding before decoding. + padding = 4 - len(publish_data_raw) % 4 + if padding != 4: + publish_data_raw += '=' * padding + courses = json.loads(base64.b64decode(publish_data_raw).decode('utf-8')) + except Exception as exc: + logger.warning('provision_lti_bulk_contexts: failed to decode publishdata: %s', exc) + return + + if not isinstance(courses, list): + logger.warning('provision_lti_bulk_contexts: publishdata is not a list') + return + + for course in courses: + try: + course_id = str(course.get('id', '')).strip() + + # Always skip the Moodle site course. + if not course_id or course_id == '1': + continue + + fullname = course.get('fullname', '') + shortname = course.get('shortname', '') + group_role = _MOODLE_ROLE_TO_GROUP_ROLE.get(course.get('role', 'student'), 'member') + + # ── Category & group ────────────────────────────────────────── + # Reuse the same .filter(...).first() pattern as provision_lti_context + # so that a real course-specific launch later finds the same record. + resource_link = LTIResourceLink.objects.filter( + platform=platform, + context_id=course_id, + ).first() + + if resource_link: + category = resource_link.category + rbac_group = resource_link.rbac_group + + # Keep the category title in sync with the LMS. + if fullname and category and category.title != fullname: + category.title = fullname + category.save(update_fields=['title']) + else: + category = Category.objects.create( + title=fullname or shortname or f'Course {course_id}', + description=f'Auto-created from {platform.name}: {fullname}', + is_global=False, + is_rbac_category=True, + is_lms_course=True, + lti_platform=platform, + lti_context_id=course_id, + ) + + rbac_group = RBACGroup.objects.create( + name=f'{fullname or shortname} ({platform.name})', + description=f'LTI course group from {platform.name}', + ) + rbac_group.categories.add(category) + + # Use a synthetic resource_link_id so provision_lti_context + # can find and reuse this record when the user later launches + # from an actual course page (it searches by platform+context_id + # without filtering on resource_link_id). + LTIResourceLink.objects.create( + platform=platform, + context_id=course_id, + resource_link_id=f'bulk_{course_id}', + context_title=fullname, + context_label=shortname, + category=category, + rbac_group=rbac_group, + ) + + if rbac_group is None: + continue + + # ── Membership ──────────────────────────────────────────────── + existing = RBACMembership.objects.filter(user=user, rbac_group=rbac_group) + if existing.exists(): + # Upgrade role if the LMS reports a higher privilege than stored. + current_role = existing.first().role + final_role = get_higher_privilege_group(current_role, group_role) + if final_role != current_role: + m = existing.first() + m.role = final_role + m.save(update_fields=['role']) + else: + try: + RBACMembership.objects.create(user=user, rbac_group=rbac_group, role=group_role) + except Exception: + pass + + except Exception as exc: + logger.warning( + 'provision_lti_bulk_contexts: error processing course %s: %s', + course.get('id'), + exc, + ) + continue + + def apply_lti_roles(user, platform, lti_roles, rbac_group): """ Apply role mappings from LTI to MediaCMS diff --git a/lti/views.py b/lti/views.py index 8aea2d96..d0b15110 100644 --- a/lti/views.py +++ b/lti/views.py @@ -36,6 +36,7 @@ from .adapters import DjangoRequest, DjangoSessionService, DjangoToolConfig from .handlers import ( apply_lti_roles, create_lti_session, + provision_lti_bulk_contexts, provision_lti_context, provision_lti_user, validate_lti_session, @@ -327,13 +328,26 @@ class LaunchView(View): # This ensures filter launches (which are deep linking) have authenticated user user = provision_lti_user(platform, launch_data) - if 'https://purl.imsglobal.org/spec/lti/claim/context' in launch_data: - category, rbac_group, resource_link_obj = provision_lti_context(platform, launch_data, resource_link_id) + context_claim = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/context', {}) + context_id = context_claim.get('id', '') + # Skip category/group creation for the Moodle site course (ID 1). + # My Media launches use Course 1 as a dummy context; real provisioning + # for those launches happens below via provision_lti_bulk_contexts. + is_site_context = str(context_id) == '1' + + if context_claim and not is_site_context: + category, rbac_group, resource_link_obj = provision_lti_context(platform, launch_data, resource_link_id) apply_lti_roles(user, platform, roles, rbac_group) else: resource_link_obj = None + # Bulk-provision all enrolled courses when the LMS sends custom_publishdata + # (only present on My Media launches; transparent on normal course launches). + publish_data_raw = custom_claims.get('publishdata') or custom_claims.get('custom_publishdata') + if publish_data_raw: + provision_lti_bulk_contexts(platform, user, publish_data_raw) + create_lti_session(request, user, message_launch, platform) message_type = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/message_type')