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

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