mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-03-09 22:47:21 -04:00
b
This commit is contained in:
@@ -31,49 +31,18 @@ Installation
|
|||||||
- Go to: Site Administration → Notifications
|
- Go to: Site Administration → Notifications
|
||||||
- Click "Upgrade Moodle database now"
|
- Click "Upgrade Moodle database now"
|
||||||
- Both plugins will be installed automatically
|
- Both plugins will be installed automatically
|
||||||
|
- Set the MediaCMS tool under the LTI Tool
|
||||||
|
|
||||||
4. Make sure Filter is enabled
|
4. Make sure Filter is enabled
|
||||||
- As Administrator, visit Plugins, 'Manage Filters', find MediaCMS filter and enable it.
|
- As Administrator, visit Plugins, 'Manage Filters', find MediaCMS filter and enable it.
|
||||||
Then place it at the top of the filter. This is important, otherwise embeds won't load.
|
Then place it at the top of the filter. This is important, otherwise embeds won't load.
|
||||||
CONFIGURATION
|
|
||||||
-------------
|
|
||||||
|
|
||||||
1. CORE SETTINGS (Required)
|
5. Enter 'My Media' on top navigation.
|
||||||
Site Administration → Plugins → Filters → MediaCMS (Settings)
|
- Log in as Administrator
|
||||||
|
- Go to: Site Administration → Appearance → Advanced Theme settings -> Custom menu items:
|
||||||
|
add: My Media|/filter/mediacms/my_media.php
|
||||||
|
|
||||||
- MediaCMS URL: https://lti.mediacms.io (your instance)
|
What to expect
|
||||||
- LTI Tool: Select your MediaCMS tool (see LTI SETUP below)
|
|
||||||
|
|
||||||
✓ These settings are shared by both plugins!
|
|
||||||
|
|
||||||
2. ENABLE FILTER
|
|
||||||
Site Administration → Plugins → Filters → Manage filters
|
|
||||||
|
|
||||||
- Set "MediaCMS" to "On"
|
|
||||||
|
|
||||||
3. LTI SETUP (Required for Video Library)
|
|
||||||
Site Administration → Plugins → Activity modules → External tool →
|
|
||||||
Manage tools → "Configure a tool manually"
|
|
||||||
|
|
||||||
- Tool name: MediaCMS
|
|
||||||
- Tool URL: https://lti.mediacms.io/lti/login/
|
|
||||||
- LTI version: LTI 1.3
|
|
||||||
- Public key type: Keyset URL
|
|
||||||
- Public keyset: https://lti.mediacms.io/lti/jwks/
|
|
||||||
- Initiate login URL: https://lti.mediacms.io/lti/login/
|
|
||||||
- Redirection URI(s): https://lti.mediacms.io/lti/launch/
|
|
||||||
|
|
||||||
Services:
|
|
||||||
✓ IMS LTI Deep Linking (required for video library)
|
|
||||||
|
|
||||||
Save and copy the configuration URLs to provide to MediaCMS admin.
|
|
||||||
|
|
||||||
4. AUTO-CONVERT DEFAULTS (Optional)
|
|
||||||
Site Administration → Plugins → Text editors → TinyMCE → MediaCMS
|
|
||||||
|
|
||||||
Configure default display options for pasted URLs.
|
|
||||||
|
|
||||||
TESTING
|
|
||||||
-------
|
-------
|
||||||
|
|
||||||
1. Create a test course
|
1. Create a test course
|
||||||
@@ -81,19 +50,6 @@ TESTING
|
|||||||
3. Click MediaCMS button in TinyMCE editor
|
3. Click MediaCMS button in TinyMCE editor
|
||||||
4. Try inserting from video library or pasting a URL
|
4. Try inserting from video library or pasting a URL
|
||||||
|
|
||||||
TROUBLESHOOTING
|
|
||||||
---------------
|
|
||||||
|
|
||||||
Video library won't load:
|
|
||||||
- Check LTI tool is selected in filter settings
|
|
||||||
- Verify you're in a course context
|
|
||||||
- Check LTI tool configuration
|
|
||||||
|
|
||||||
URLs not auto-converting:
|
|
||||||
- Enable MediaCMS filter in Manage filters
|
|
||||||
- Verify MediaCMS URL setting matches your instance
|
|
||||||
- Clear caches: Site Administration → Development → Purge caches
|
|
||||||
|
|
||||||
SUPPORT
|
SUPPORT
|
||||||
-------
|
-------
|
||||||
Issues: https://github.com/mediacms-io/mediacms/issues
|
Issues: https://github.com/mediacms-io/mediacms/issues
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ try {
|
|||||||
$cm = get_coursemodule_from_id('lti', $dummy_cmid, 0, false, MUST_EXIST);
|
$cm = get_coursemodule_from_id('lti', $dummy_cmid, 0, false, MUST_EXIST);
|
||||||
$instance = $DB->get_record('lti', ['id' => $cm->instance], '*', MUST_EXIST);
|
$instance = $DB->get_record('lti', ['id' => $cm->instance], '*', MUST_EXIST);
|
||||||
|
|
||||||
|
// DEBUG: log enrolled courses retrieved.
|
||||||
|
error_log('MediaCMS My Media publishdata courses (' . count($publish_data) . '): ' . json_encode($publish_data));
|
||||||
|
|
||||||
|
// Write publishdata to DB — Moodle's auth.php re-reads the instance from DB
|
||||||
|
// when building the LTI launch JWT, so in-memory changes are ignored.
|
||||||
|
$DB->set_field('lti', 'instructorcustomparameters', 'publishdata=' . $publishdata_b64, ['id' => $cm->instance]);
|
||||||
$instance->instructorcustomparameters = 'publishdata=' . $publishdata_b64;
|
$instance->instructorcustomparameters = 'publishdata=' . $publishdata_b64;
|
||||||
$instance->name = 'MediaCMS My Media';
|
$instance->name = 'MediaCMS My Media';
|
||||||
|
|
||||||
|
|||||||
407
lti/handlers.py
407
lti/handlers.py
@@ -27,27 +27,117 @@ from .models import LTIResourceLink, LTIRoleMapping, LTIUserMapping
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_LTI_ROLE_MAPPINGS = {
|
DEFAULT_LTI_ROLE_MAPPINGS = {
|
||||||
|
# LTI role names (used in standard launches)
|
||||||
'Instructor': {'global_role': '', 'group_role': 'manager'},
|
'Instructor': {'global_role': '', 'group_role': 'manager'},
|
||||||
'TeachingAssistant': {'global_role': '', 'group_role': 'contributor'},
|
'TeachingAssistant': {'global_role': '', 'group_role': 'contributor'},
|
||||||
'Learner': {'global_role': '', 'group_role': 'member'},
|
'Learner': {'global_role': '', 'group_role': 'member'},
|
||||||
'Student': {'global_role': '', 'group_role': 'member'},
|
'Student': {'global_role': '', 'group_role': 'member'},
|
||||||
'Administrator': {'global_role': '', 'group_role': 'manager'},
|
'Administrator': {'global_role': '', 'group_role': 'manager'},
|
||||||
'Faculty': {'global_role': '', 'group_role': 'manager'},
|
'Faculty': {'global_role': '', 'group_role': 'manager'},
|
||||||
|
# Moodle role shortnames (used in custom_publishdata from My Media launches)
|
||||||
|
'student': {'global_role': '', 'group_role': 'member'},
|
||||||
|
'guest': {'global_role': '', 'group_role': 'member'},
|
||||||
|
'teacher': {'global_role': '', 'group_role': 'manager'},
|
||||||
|
'editingteacher': {'global_role': '', 'group_role': 'manager'},
|
||||||
|
'manager': {'global_role': '', 'group_role': 'manager'},
|
||||||
|
'coursecreator': {'global_role': '', 'group_role': 'manager'},
|
||||||
|
'ta': {'global_role': '', 'group_role': 'contributor'},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_course_context(platform, context_id, title, label, resource_link_id):
|
||||||
|
"""
|
||||||
|
Find or create the LTIResourceLink, Category, and RBACGroup for a course.
|
||||||
|
|
||||||
|
When a record already exists (e.g. created by a bulk My Media launch), it is
|
||||||
|
reused and its metadata is kept in sync. The resource_link_id is only
|
||||||
|
promoted when a real launch ID arrives to replace a 'bulk_*' placeholder.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (category, rbac_group, resource_link)
|
||||||
|
"""
|
||||||
|
resource_link = LTIResourceLink.objects.filter(
|
||||||
|
platform=platform,
|
||||||
|
context_id=context_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if resource_link:
|
||||||
|
category = resource_link.category
|
||||||
|
rbac_group = resource_link.rbac_group
|
||||||
|
|
||||||
|
rl_updates = []
|
||||||
|
if title and resource_link.context_title != title:
|
||||||
|
resource_link.context_title = title
|
||||||
|
rl_updates.append('context_title')
|
||||||
|
if label and resource_link.context_label != label:
|
||||||
|
resource_link.context_label = label
|
||||||
|
rl_updates.append('context_label')
|
||||||
|
# Promote from bulk placeholder to real resource link ID.
|
||||||
|
if resource_link_id and not resource_link_id.startswith('bulk_') and resource_link.resource_link_id != resource_link_id:
|
||||||
|
resource_link.resource_link_id = resource_link_id
|
||||||
|
rl_updates.append('resource_link_id')
|
||||||
|
if rl_updates:
|
||||||
|
resource_link.save(update_fields=rl_updates)
|
||||||
|
|
||||||
|
if title and category and category.title != title:
|
||||||
|
category.title = title
|
||||||
|
category.save(update_fields=['title'])
|
||||||
|
|
||||||
|
else:
|
||||||
|
category = Category.objects.create(
|
||||||
|
title=title or label or f'Course {context_id}',
|
||||||
|
description=f'Auto-created from {platform.name}: {title}',
|
||||||
|
is_global=False,
|
||||||
|
is_rbac_category=True,
|
||||||
|
is_lms_course=True,
|
||||||
|
lti_platform=platform,
|
||||||
|
lti_context_id=context_id,
|
||||||
|
)
|
||||||
|
rbac_group = RBACGroup.objects.create(
|
||||||
|
name=f'{title or label} ({platform.name})',
|
||||||
|
description=f'LTI course group from {platform.name}',
|
||||||
|
)
|
||||||
|
rbac_group.categories.add(category)
|
||||||
|
resource_link = LTIResourceLink.objects.create(
|
||||||
|
platform=platform,
|
||||||
|
context_id=context_id,
|
||||||
|
resource_link_id=resource_link_id,
|
||||||
|
context_title=title,
|
||||||
|
context_label=label,
|
||||||
|
category=category,
|
||||||
|
rbac_group=rbac_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
return category, rbac_group, resource_link
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_membership(user, rbac_group, group_role):
|
||||||
|
"""
|
||||||
|
Ensure the user is a member of rbac_group with at least group_role.
|
||||||
|
Upgrades the role if the user already has a lower one; never downgrades.
|
||||||
|
"""
|
||||||
|
existing = RBACMembership.objects.filter(user=user, rbac_group=rbac_group).first()
|
||||||
|
if existing:
|
||||||
|
final_role = get_higher_privilege_group(existing.role, group_role)
|
||||||
|
if final_role != existing.role:
|
||||||
|
existing.role = final_role
|
||||||
|
existing.save(update_fields=['role'])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
RBACMembership.objects.create(user=user, rbac_group=rbac_group, role=group_role)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def provision_lti_user(platform, claims):
|
def provision_lti_user(platform, claims):
|
||||||
"""
|
"""
|
||||||
Provision MediaCMS user from LTI launch claims
|
Provision MediaCMS user from LTI launch claims.
|
||||||
|
|
||||||
Args:
|
|
||||||
platform: LTIPlatform instance
|
|
||||||
claims: Dict of LTI launch claims
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User instance
|
User instance
|
||||||
|
|
||||||
Pattern: Similar to saml_auth.adapter.perform_user_actions()
|
|
||||||
"""
|
"""
|
||||||
lti_user_id = claims.get('sub')
|
lti_user_id = claims.get('sub')
|
||||||
if not lti_user_id:
|
if not lti_user_id:
|
||||||
@@ -67,15 +157,12 @@ def provision_lti_user(platform, claims):
|
|||||||
if email and user.email != email:
|
if email and user.email != email:
|
||||||
user.email = email
|
user.email = email
|
||||||
update_fields.append('email')
|
update_fields.append('email')
|
||||||
|
|
||||||
if given_name and user.first_name != given_name:
|
if given_name and user.first_name != given_name:
|
||||||
user.first_name = given_name
|
user.first_name = given_name
|
||||||
update_fields.append('first_name')
|
update_fields.append('first_name')
|
||||||
|
|
||||||
if family_name and user.last_name != family_name:
|
if family_name and user.last_name != family_name:
|
||||||
user.last_name = family_name
|
user.last_name = family_name
|
||||||
update_fields.append('last_name')
|
update_fields.append('last_name')
|
||||||
|
|
||||||
if name and user.name != name:
|
if name and user.name != name:
|
||||||
user.name = name
|
user.name = name
|
||||||
update_fields.append('name')
|
update_fields.append('name')
|
||||||
@@ -85,11 +172,17 @@ def provision_lti_user(platform, claims):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
username = generate_username_from_lti(lti_user_id, email, given_name, family_name)
|
username = generate_username_from_lti(lti_user_id, email, given_name, family_name)
|
||||||
|
|
||||||
if User.objects.filter(username=username).exists():
|
if User.objects.filter(username=username).exists():
|
||||||
username = f"{username}_{hashlib.md5(lti_user_id.encode()).hexdigest()[:6]}"
|
username = f"{username}_{hashlib.md5(lti_user_id.encode()).hexdigest()[:6]}"
|
||||||
|
|
||||||
user = User.objects.create_user(username=username, email=email or '', first_name=given_name, last_name=family_name, name=name or username, is_active=True)
|
user = User.objects.create_user(
|
||||||
|
username=username,
|
||||||
|
email=email or '',
|
||||||
|
first_name=given_name,
|
||||||
|
last_name=family_name,
|
||||||
|
name=name or username,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
if email:
|
if email:
|
||||||
try:
|
try:
|
||||||
@@ -103,13 +196,12 @@ def provision_lti_user(platform, claims):
|
|||||||
|
|
||||||
|
|
||||||
def generate_username_from_lti(lti_user_id, email, given_name, family_name):
|
def generate_username_from_lti(lti_user_id, email, given_name, family_name):
|
||||||
"""Generate a username from LTI user info"""
|
"""Generate a username from LTI user info."""
|
||||||
|
|
||||||
if email and '@' in email:
|
if email and '@' in email:
|
||||||
username = email.split('@')[0]
|
username = email.split('@')[0]
|
||||||
username = ''.join(c if c.isalnum() or c in '_-' else '_' for c in username)
|
username = ''.join(c if c.isalnum() or c in '_-' else '_' for c in username)
|
||||||
if len(username) >= 4:
|
if len(username) >= 4:
|
||||||
return username[:30] # Max 30 chars
|
return username[:30]
|
||||||
|
|
||||||
if given_name and family_name:
|
if given_name and family_name:
|
||||||
username = f"{given_name}.{family_name}".lower()
|
username = f"{given_name}.{family_name}".lower()
|
||||||
@@ -117,120 +209,38 @@ def generate_username_from_lti(lti_user_id, email, given_name, family_name):
|
|||||||
if len(username) >= 4:
|
if len(username) >= 4:
|
||||||
return username[:30]
|
return username[:30]
|
||||||
|
|
||||||
user_hash = hashlib.md5(lti_user_id.encode()).hexdigest()[:10]
|
return f"lti_user_{hashlib.md5(lti_user_id.encode()).hexdigest()[:10]}"
|
||||||
return f"lti_user_{user_hash}"
|
|
||||||
|
|
||||||
|
|
||||||
def provision_lti_context(platform, claims, resource_link_id):
|
def provision_lti_context(platform, claims, resource_link_id):
|
||||||
"""
|
"""
|
||||||
Provision MediaCMS category and RBAC group for LTI context (course)
|
Provision MediaCMS category and RBAC group for an LTI context (course).
|
||||||
|
|
||||||
Args:
|
|
||||||
platform: LTIPlatform instance
|
|
||||||
claims: Dict of LTI launch claims
|
|
||||||
resource_link_id: Resource link ID
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (category, rbac_group, resource_link)
|
Tuple of (category, rbac_group, resource_link)
|
||||||
|
|
||||||
Pattern: Integrates with existing Category and RBACGroup models
|
|
||||||
"""
|
"""
|
||||||
context = claims.get('https://purl.imsglobal.org/spec/lti/claim/context', {})
|
context = claims.get('https://purl.imsglobal.org/spec/lti/claim/context', {})
|
||||||
context_id = context.get('id')
|
context_id = context.get('id')
|
||||||
if not context_id:
|
if not context_id:
|
||||||
raise ValueError("Missing context ID in LTI launch")
|
raise ValueError("Missing context ID in LTI launch")
|
||||||
|
|
||||||
context_title = context.get('title', '')
|
return _ensure_course_context(
|
||||||
context_label = context.get('label', '')
|
|
||||||
|
|
||||||
resource_link = LTIResourceLink.objects.filter(
|
|
||||||
platform=platform,
|
platform=platform,
|
||||||
context_id=context_id,
|
context_id=str(context_id),
|
||||||
).first()
|
title=context.get('title', ''),
|
||||||
|
label=context.get('label', ''),
|
||||||
if resource_link:
|
resource_link_id=resource_link_id,
|
||||||
category = resource_link.category
|
)
|
||||||
rbac_group = resource_link.rbac_group
|
|
||||||
|
|
||||||
update_fields = []
|
|
||||||
if context_title and resource_link.context_title != context_title:
|
|
||||||
resource_link.context_title = context_title
|
|
||||||
update_fields.append('context_title')
|
|
||||||
if context_label and resource_link.context_label != context_label:
|
|
||||||
resource_link.context_label = context_label
|
|
||||||
update_fields.append('context_label')
|
|
||||||
# TODO / TOCHECK: consider whether we need to update this or not
|
|
||||||
if resource_link.resource_link_id != resource_link_id:
|
|
||||||
resource_link.resource_link_id = resource_link_id
|
|
||||||
update_fields.append('resource_link_id')
|
|
||||||
|
|
||||||
if update_fields:
|
|
||||||
resource_link.save(update_fields=update_fields)
|
|
||||||
|
|
||||||
if context_title and category and category.title != context_title:
|
|
||||||
category.title = context_title
|
|
||||||
category.save(update_fields=['title'])
|
|
||||||
|
|
||||||
else:
|
|
||||||
category = Category.objects.create(
|
|
||||||
title=context_title or context_label or f"Course {context_id}",
|
|
||||||
description=f"Auto-created from {platform.name}: {context_title}",
|
|
||||||
is_global=False,
|
|
||||||
is_rbac_category=True,
|
|
||||||
is_lms_course=True,
|
|
||||||
lti_platform=platform,
|
|
||||||
lti_context_id=context_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
rbac_group = RBACGroup.objects.create(
|
|
||||||
name=f"{context_title or context_label} ({platform.name})",
|
|
||||||
description=f"LTI course group from {platform.name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
rbac_group.categories.add(category)
|
|
||||||
|
|
||||||
resource_link = LTIResourceLink.objects.create(
|
|
||||||
platform=platform,
|
|
||||||
context_id=context_id,
|
|
||||||
resource_link_id=resource_link_id,
|
|
||||||
context_title=context_title,
|
|
||||||
context_label=context_label,
|
|
||||||
category=category,
|
|
||||||
rbac_group=rbac_group,
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
def provision_lti_bulk_contexts(platform, user, publish_data_raw):
|
||||||
"""
|
"""
|
||||||
Bulk-provision categories, groups, and memberships for every course the
|
Bulk-provision categories, groups, and memberships for every course the
|
||||||
user is enrolled in, as reported by the LMS via the custom_publishdata
|
user is enrolled in, as reported by the LMS via custom_publishdata.
|
||||||
parameter.
|
|
||||||
|
|
||||||
Called on My Media launches where there is no specific course context.
|
Called on My Media launches. Skips the Moodle site course (ID 1).
|
||||||
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:
|
try:
|
||||||
# Restore any stripped base64 padding before decoding.
|
|
||||||
padding = 4 - len(publish_data_raw) % 4
|
padding = 4 - len(publish_data_raw) % 4
|
||||||
if padding != 4:
|
if padding != 4:
|
||||||
publish_data_raw += '=' * padding
|
publish_data_raw += '=' * padding
|
||||||
@@ -246,80 +256,23 @@ def provision_lti_bulk_contexts(platform, user, publish_data_raw):
|
|||||||
for course in courses:
|
for course in courses:
|
||||||
try:
|
try:
|
||||||
course_id = str(course.get('id', '')).strip()
|
course_id = str(course.get('id', '')).strip()
|
||||||
|
if not course_id:
|
||||||
# Always skip the Moodle site course.
|
|
||||||
if not course_id or course_id == '1':
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
fullname = course.get('fullname', '')
|
fullname = course.get('fullname', '')
|
||||||
shortname = course.get('shortname', '')
|
shortname = course.get('shortname', '')
|
||||||
group_role = _MOODLE_ROLE_TO_GROUP_ROLE.get(course.get('role', 'student'), 'member')
|
group_role = DEFAULT_LTI_ROLE_MAPPINGS.get(course.get('role', 'student'), {}).get('group_role', 'member')
|
||||||
|
|
||||||
# ── Category & group ──────────────────────────────────────────
|
_, rbac_group, _ = _ensure_course_context(
|
||||||
# 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,
|
platform=platform,
|
||||||
context_id=course_id,
|
context_id=course_id,
|
||||||
).first()
|
title=fullname,
|
||||||
|
label=shortname,
|
||||||
|
resource_link_id=f'bulk_{course_id}',
|
||||||
|
)
|
||||||
|
|
||||||
if resource_link:
|
if rbac_group:
|
||||||
category = resource_link.category
|
_ensure_membership(user, rbac_group, group_role)
|
||||||
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:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -327,26 +280,17 @@ def provision_lti_bulk_contexts(platform, user, publish_data_raw):
|
|||||||
course.get('id'),
|
course.get('id'),
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
def apply_lti_roles(user, platform, lti_roles, rbac_group):
|
def apply_lti_roles(user, platform, lti_roles, rbac_group):
|
||||||
"""
|
"""
|
||||||
Apply role mappings from LTI to MediaCMS
|
Apply role mappings from LTI role URIs to MediaCMS global and group roles.
|
||||||
|
|
||||||
Args:
|
Returns:
|
||||||
user: User instance
|
Tuple of (global_role, group_role)
|
||||||
platform: LTIPlatform instance
|
|
||||||
lti_roles: List of LTI role URIs
|
|
||||||
rbac_group: RBACGroup instance for course
|
|
||||||
|
|
||||||
Pattern: Similar to saml_auth.adapter.handle_role_mapping()
|
|
||||||
"""
|
"""
|
||||||
if not lti_roles:
|
|
||||||
lti_roles = []
|
|
||||||
|
|
||||||
short_roles = []
|
short_roles = []
|
||||||
for role in lti_roles:
|
for role in lti_roles or []:
|
||||||
if '#' in role:
|
if '#' in role:
|
||||||
short_roles.append(role.split('#')[-1])
|
short_roles.append(role.split('#')[-1])
|
||||||
elif '/' in role:
|
elif '/' in role:
|
||||||
@@ -354,89 +298,50 @@ def apply_lti_roles(user, platform, lti_roles, rbac_group):
|
|||||||
else:
|
else:
|
||||||
short_roles.append(role)
|
short_roles.append(role)
|
||||||
|
|
||||||
custom_mappings = {}
|
custom_mappings = {m.lti_role: {'global_role': m.global_role, 'group_role': m.group_role} for m in LTIRoleMapping.objects.filter(platform=platform)}
|
||||||
for mapping in LTIRoleMapping.objects.filter(platform=platform):
|
|
||||||
custom_mappings[mapping.lti_role] = {
|
|
||||||
'global_role': mapping.global_role,
|
|
||||||
'group_role': mapping.group_role,
|
|
||||||
}
|
|
||||||
|
|
||||||
all_mappings = {**DEFAULT_LTI_ROLE_MAPPINGS, **custom_mappings}
|
all_mappings = {**DEFAULT_LTI_ROLE_MAPPINGS, **custom_mappings}
|
||||||
|
|
||||||
global_role = 'user'
|
global_role = 'user'
|
||||||
for role in short_roles:
|
|
||||||
if role in all_mappings:
|
|
||||||
role_global = all_mappings[role].get('global_role')
|
|
||||||
if role_global:
|
|
||||||
global_role = get_higher_privilege_global(global_role, role_global)
|
|
||||||
|
|
||||||
user.set_role_from_mapping(global_role)
|
|
||||||
|
|
||||||
group_role = 'member'
|
group_role = 'member'
|
||||||
for role in short_roles:
|
for role in short_roles:
|
||||||
if role in all_mappings:
|
if role in all_mappings:
|
||||||
role_group = all_mappings[role].get('group_role')
|
if all_mappings[role].get('global_role'):
|
||||||
if role_group:
|
global_role = get_higher_privilege_global(global_role, all_mappings[role]['global_role'])
|
||||||
group_role = get_higher_privilege_group(group_role, role_group)
|
if all_mappings[role].get('group_role'):
|
||||||
|
group_role = get_higher_privilege_group(group_role, all_mappings[role]['group_role'])
|
||||||
|
|
||||||
memberships = RBACMembership.objects.filter(user=user, rbac_group=rbac_group)
|
user.set_role_from_mapping(global_role)
|
||||||
|
_ensure_membership(user, rbac_group, group_role)
|
||||||
if memberships.exists():
|
|
||||||
if not memberships.filter(role=group_role).exists():
|
|
||||||
first_membership = memberships.first()
|
|
||||||
first_membership.role = group_role
|
|
||||||
try:
|
|
||||||
first_membership.save()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
RBACMembership.objects.create(user=user, rbac_group=rbac_group, role=group_role)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return global_role, group_role
|
return global_role, group_role
|
||||||
|
|
||||||
|
|
||||||
def get_higher_privilege_global(role1, role2):
|
def get_higher_privilege_global(role1, role2):
|
||||||
"""Return the higher privilege global role"""
|
"""Return the higher privilege global role."""
|
||||||
privilege_order = ['user', 'advancedUser', 'editor', 'manager', 'admin']
|
privilege_order = ['user', 'advancedUser', 'editor', 'manager', 'admin']
|
||||||
try:
|
try:
|
||||||
index1 = privilege_order.index(role1)
|
return privilege_order[max(privilege_order.index(role1), privilege_order.index(role2))]
|
||||||
index2 = privilege_order.index(role2)
|
|
||||||
return privilege_order[max(index1, index2)]
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return role2 # Default to role2 if role1 is unknown
|
return role2
|
||||||
|
|
||||||
|
|
||||||
def get_higher_privilege_group(role1, role2):
|
def get_higher_privilege_group(role1, role2):
|
||||||
"""Return the higher privilege group role"""
|
"""Return the higher privilege group role."""
|
||||||
privilege_order = ['member', 'contributor', 'manager']
|
privilege_order = ['member', 'contributor', 'manager']
|
||||||
try:
|
try:
|
||||||
index1 = privilege_order.index(role1)
|
return privilege_order[max(privilege_order.index(role1), privilege_order.index(role2))]
|
||||||
index2 = privilege_order.index(role2)
|
|
||||||
return privilege_order[max(index1, index2)]
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return role2 # Default to role2 if role1 is unknown
|
return role2
|
||||||
|
|
||||||
|
|
||||||
def create_lti_session(request, user, launch_data, platform):
|
def create_lti_session(request, user, launch_data, platform):
|
||||||
"""
|
"""Create a MediaCMS session from an LTI launch."""
|
||||||
Create MediaCMS session from LTI launch
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Django request
|
|
||||||
user: User instance
|
|
||||||
launch_data: Dict of validated LTI launch data
|
|
||||||
platform: LTIPlatform instance
|
|
||||||
|
|
||||||
Pattern: Uses Django's session framework
|
|
||||||
"""
|
|
||||||
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
|
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
|
||||||
|
|
||||||
context = launch_data.get_launch_data().get('https://purl.imsglobal.org/spec/lti/claim/context', {})
|
ld = launch_data.get_launch_data()
|
||||||
resource_link = launch_data.get_launch_data().get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {})
|
context = ld.get('https://purl.imsglobal.org/spec/lti/claim/context', {})
|
||||||
roles = launch_data.get_launch_data().get('https://purl.imsglobal.org/spec/lti/claim/roles', [])
|
resource_link = ld.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {})
|
||||||
|
roles = ld.get('https://purl.imsglobal.org/spec/lti/claim/roles', [])
|
||||||
|
|
||||||
request.session['lti_session'] = {
|
request.session['lti_session'] = {
|
||||||
'platform_id': platform.id,
|
'platform_id': platform.id,
|
||||||
@@ -448,10 +353,7 @@ def create_lti_session(request, user, launch_data, platform):
|
|||||||
'launch_time': timezone.now().isoformat(),
|
'launch_time': timezone.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout = getattr(settings, 'LTI_SESSION_TIMEOUT', 3600)
|
request.session.set_expiry(getattr(settings, 'LTI_SESSION_TIMEOUT', 3600))
|
||||||
request.session.set_expiry(timeout)
|
|
||||||
|
|
||||||
# CRITICAL: Explicitly save session before redirect (for cross-site contexts)
|
|
||||||
request.session.modified = True
|
request.session.modified = True
|
||||||
request.session.save()
|
request.session.save()
|
||||||
|
|
||||||
@@ -460,18 +362,11 @@ def create_lti_session(request, user, launch_data, platform):
|
|||||||
|
|
||||||
def validate_lti_session(request):
|
def validate_lti_session(request):
|
||||||
"""
|
"""
|
||||||
Validate that an LTI session exists and is valid
|
Validate that an LTI session exists and is valid.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict of LTI session data or None
|
Dict of LTI session data or None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
lti_session = request.session.get('lti_session')
|
|
||||||
|
|
||||||
if not lti_session:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return None
|
return None
|
||||||
|
return request.session.get('lti_session')
|
||||||
return lti_session
|
|
||||||
|
|||||||
24
lti/views.py
24
lti/views.py
@@ -331,23 +331,25 @@ class LaunchView(View):
|
|||||||
context_claim = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/context', {})
|
context_claim = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/context', {})
|
||||||
context_id = context_claim.get('id', '')
|
context_id = context_claim.get('id', '')
|
||||||
|
|
||||||
# Skip category/group creation for the Moodle site course (ID 1).
|
# Detect My Media launches: publishdata is only sent on My Media launches.
|
||||||
# My Media launches use Course 1 as a dummy context; real provisioning
|
publish_data_raw = custom_claims.get('publishdata') or custom_claims.get('custom_publishdata')
|
||||||
# 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:
|
print(f"[MediaCMS LTI] context_id={context_id!r} publish_data_raw present={bool(publish_data_raw)}")
|
||||||
|
if publish_data_raw:
|
||||||
|
print(f"[MediaCMS LTI] publishdata (raw, first 200 chars): {publish_data_raw[:200]}")
|
||||||
|
|
||||||
|
if publish_data_raw:
|
||||||
|
# My Media launch: provision all enrolled courses from publishdata.
|
||||||
|
# Skip individual context provisioning to avoid double-provisioning.
|
||||||
|
resource_link_obj = None
|
||||||
|
provision_lti_bulk_contexts(platform, user, publish_data_raw)
|
||||||
|
elif context_claim:
|
||||||
|
# Normal course launch: provision only this context.
|
||||||
category, rbac_group, resource_link_obj = provision_lti_context(platform, launch_data, resource_link_id)
|
category, rbac_group, resource_link_obj = provision_lti_context(platform, launch_data, resource_link_id)
|
||||||
apply_lti_roles(user, platform, roles, rbac_group)
|
apply_lti_roles(user, platform, roles, rbac_group)
|
||||||
else:
|
else:
|
||||||
resource_link_obj = None
|
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)
|
create_lti_session(request, user, message_launch, platform)
|
||||||
|
|
||||||
message_type = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/message_type')
|
message_type = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/message_type')
|
||||||
|
|||||||
Reference in New Issue
Block a user