This commit is contained in:
Markos Gogoulos
2026-02-19 13:00:41 +02:00
parent 96755af3b2
commit 21ddd04165
11 changed files with 486 additions and 58 deletions

View File

@@ -0,0 +1,49 @@
<?php
/**
* Hook listener for filter_mediacms navigation hooks (Moodle 4.3+)
*
* @package filter_mediacms
* @copyright 2026 MediaCMS
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace filter_mediacms;
/**
* Extends the primary (top) navigation bar with a My Media link.
*/
class hook_listener {
/**
* Called by the \core\hook\navigation\primary_extend hook.
* Adds the My Media link to the primary nav bar when placement = 'top'.
*/
public static function extend_primary_navigation(
\core\hook\navigation\primary_extend $hook
): void {
$placement = get_config('filter_mediacms', 'mymedia_placement');
if ($placement !== 'top') {
return;
}
if (!isloggedin() || isguestuser()) {
return;
}
$url = new \moodle_url('/filter/mediacms/my_media.php');
$node = \navigation_node::create(
get_string('mymedia', 'filter_mediacms'),
$url,
\navigation_node::TYPE_CUSTOM,
null,
'mediacms_mymedia',
new \pix_icon('i/media', '')
);
$primarynav = $hook->get_primarynav();
if ($primarynav === null) {
return;
}
$primarynav->add_node($node);
}
}

View File

@@ -0,0 +1,13 @@
<?php
/**
* Hook registrations for filter_mediacms.
* Primary navigation is handled via extend_navigation() in lib.php instead.
*
* @package filter_mediacms
* @copyright 2026 MediaCMS
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$callbacks = [];

View File

@@ -17,3 +17,13 @@ $string['iframeheight_desc'] = 'Default height for embedded videos (pixels).';
$string['enableautoconvert'] = 'Auto-convert URLs';
$string['enableautoconvert_desc'] = 'Automatically convert MediaCMS URLs (e.g., /view?m=xyz) in text to embedded players.';
$string['privacy:metadata'] = 'The MediaCMS filter does not store any personal data.';
// My Media page.
$string['mymedia'] = 'My Media';
$string['mymedia_placement'] = 'My Media link placement';
$string['mymedia_placement_desc'] = 'Where to display the My Media link in the Moodle interface.';
$string['mymedia_placement_top'] = 'Top navigation';
$string['mymedia_placement_user'] = 'User navigation';
$string['notconfigured'] = 'MediaCMS is not fully configured. Please set the MediaCMS URL and LTI Tool in Site Administration → Plugins → Filters → MediaCMS.';
$string['ltitoolnotfound'] = 'The configured LTI tool could not be found. Please check the MediaCMS filter settings.';
$string['cannotcreatedummyactivity'] = 'Could not create the MediaCMS launcher activity. Please check course permissions.';

View File

@@ -11,65 +11,10 @@ require_once(__DIR__ . '/../../config.php');
require_once($CFG->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);

View File

@@ -0,0 +1,72 @@
<?php
/**
* Navigation callbacks for filter_mediacms
*
* @package filter_mediacms
* @copyright 2026 MediaCMS
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Add My Media to the global / flat navigation (nav drawer).
* Fires on every page when placement is set to 'top'.
* In Moodle 4.x Boost the nav drawer is opened via the hamburger icon
* and the node appears as a top-level item alongside Home / My courses.
*/
function filter_mediacms_extend_navigation(global_navigation $navigation): void {
$placement = get_config('filter_mediacms', 'mymedia_placement');
if ($placement !== 'top') {
return;
}
if (!isloggedin() || isguestuser()) {
return;
}
$url = new moodle_url('/filter/mediacms/my_media.php');
$node = navigation_node::create(
get_string('mymedia', 'filter_mediacms'),
$url,
navigation_node::TYPE_CUSTOM,
null,
'mediacms_mymedia',
new pix_icon('i/media', '')
);
// showinflatnavigation = true makes it visible in the Boost nav drawer.
$node->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', '')
);
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* Local helper functions for filter_mediacms
*
* @package filter_mediacms
* @copyright 2026 MediaCMS
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Find the first LTI activity for the MediaCMS tool in a course, or create a
* hidden dummy one if none exists.
*
* @param int $courseid
* @param int $typeid LTI tool type ID
* @return int course-module ID
*/
function filter_mediacms_get_dummy_activity($courseid, $typeid) {
global $DB;
$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) {
$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;
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* My Media LTI launch page — runs inside the iframe from my_media.php.
*
* Builds custom_publishdata (enrolled courses + roles) and initiates
* the LTI 1.3 OIDC login flow, outputting the auto-submit form directly.
*
* @package filter_mediacms
* @copyright 2026 MediaCMS
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__ . '/../../config.php');
require_once($CFG->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;

View File

@@ -0,0 +1,38 @@
<?php
/**
* My Media page — renders the Moodle shell with an LTI iframe.
*
* @package filter_mediacms
* @copyright 2026 MediaCMS
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__ . '/../../config.php');
global $SITE, $PAGE, $OUTPUT, $USER;
require_login();
$context = context_system::instance();
$PAGE->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();

View File

@@ -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'),
]
));
}

View File

@@ -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

View File

@@ -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')