This commit is contained in:
Markos Gogoulos
2025-12-24 17:28:12 +02:00
parent ed5cfa1a84
commit 295578dae2
22 changed files with 2345 additions and 1 deletions

View File

@@ -24,6 +24,7 @@ INSTALLED_APPS = [
"actions.apps.ActionsConfig",
"rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig",
"lti.apps.LtiConfig",
"debug_toolbar",
"mptt",
"crispy_forms",

View File

@@ -300,6 +300,7 @@ INSTALLED_APPS = [
"actions.apps.ActionsConfig",
"rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig",
"lti.apps.LtiConfig",
"debug_toolbar",
"mptt",
"crispy_forms",
@@ -555,6 +556,7 @@ DJANGO_ADMIN_URL = "admin/"
USE_SAML = False
USE_RBAC = False
USE_IDENTITY_PROVIDERS = False
USE_LTI = False # Enable LTI 1.3 integration
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
USE_ROUNDED_CORNERS = True
@@ -650,3 +652,16 @@ if USERS_NEEDS_TO_BE_APPROVED:
)
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
# LTI 1.3 Integration Settings
if USE_LTI:
# Session timeout for LTI launches (seconds)
LTI_SESSION_TIMEOUT = 3600 # 1 hour
# Cookie settings required for iframe embedding from LMS
# IMPORTANT: Requires HTTPS to be enabled
SESSION_COOKIE_SAMESITE = 'None'
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'None'
CSRF_COOKIE_SECURE = True

View File

@@ -25,6 +25,7 @@ urlpatterns = [
re_path(r"^", include("files.urls")),
re_path(r"^", include("users.urls")),
re_path(r"^accounts/", include("allauth.urls")),
re_path(r"^lti/", include("lti.urls")),
re_path(r"^api-auth/", include("rest_framework.urls")),
path(settings.DJANGO_ADMIN_URL, admin.site.urls),
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),

View File

@@ -65,7 +65,9 @@ class CategoryAdminForm(forms.ModelForm):
class Meta:
model = Category
fields = '__all__'
# Exclude LTI fields to avoid circular dependency during admin loading
# These will be managed automatically by LTI provisioning
exclude = ['lti_platform', 'lti_context_id']
def clean(self):
cleaned_data = super().clean()

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.6 on 2025-12-24 15:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0014_alter_subtitle_options_and_more'),
('lti', '__first__'),
]
operations = [
migrations.AddField(
model_name='category',
name='is_lms_course',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this category represents an LMS course'),
),
migrations.AddField(
model_name='category',
name='lti_context_id',
field=models.CharField(blank=True, db_index=True, help_text='LTI context ID from platform', max_length=255),
),
migrations.AddField(
model_name='category',
name='lti_platform',
field=models.ForeignKey(
blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform'
),
),
]

View File

@@ -47,6 +47,13 @@ class Category(models.Model):
verbose_name='IDP Config Name',
)
# LTI/LMS integration fields
is_lms_course = models.BooleanField(default=False, db_index=True, help_text='Whether this category represents an LMS course')
lti_platform = models.ForeignKey('lti.LTIPlatform', blank=True, null=True, on_delete=models.SET_NULL, related_name='categories', help_text='LTI Platform if this is an LTI course')
lti_context_id = models.CharField(max_length=255, blank=True, db_index=True, help_text='LTI context ID from platform')
def __str__(self):
return self.title

6
lti/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
LTI 1.3 Integration for MediaCMS
Enables integration with Learning Management Systems like Moodle
"""
default_app_config = 'lti.apps.LtiConfig'

203
lti/adapters.py Normal file
View File

@@ -0,0 +1,203 @@
"""
PyLTI1p3 Django adapters for MediaCMS
Provides Django-specific implementations for PyLTI1p3 interfaces
"""
import json
from typing import Any, Dict, Optional
from django.core.cache import cache
from pylti1p3.tool_config import ToolConfAbstract
class DjangoOIDCLogin:
"""Handles OIDC login initiation"""
def __init__(self, request, tool_config, launch_data_storage=None):
self.request = request
self.tool_config = tool_config
self.launch_data_storage = launch_data_storage or DjangoSessionService(request)
def get_redirect(self, redirect_url):
"""Get the redirect object for OIDC login"""
from pylti1p3.oidc_login import OIDCLogin
oidc_login = OIDCLogin(self.request, self.tool_config, launch_data_storage=self.launch_data_storage)
return oidc_login.enable_check_cookies().redirect(redirect_url)
class DjangoMessageLaunch:
"""Handles LTI message launch validation"""
def __init__(self, request, tool_config, launch_data_storage=None):
self.request = request
self.tool_config = tool_config
self.launch_data_storage = launch_data_storage or DjangoSessionService(request)
def validate(self):
"""Validate the LTI launch message"""
from pylti1p3.message_launch import MessageLaunch
message_launch = MessageLaunch(self.request, self.tool_config, launch_data_storage=self.launch_data_storage)
return message_launch
class DjangoSessionService:
"""Launch data storage using Django sessions"""
def __init__(self, request):
self.request = request
self._session_key_prefix = 'lti1p3_'
def get_launch_data(self, key):
"""Get launch data from session"""
session_key = self._session_key_prefix + key
data = self.request.session.get(session_key)
return json.loads(data) if data else None
def save_launch_data(self, key, data):
"""Save launch data to session"""
session_key = self._session_key_prefix + key
self.request.session[session_key] = json.dumps(data)
self.request.session.modified = True
return True
def check_launch_data_storage_exists(self, key):
"""Check if launch data exists in session"""
session_key = self._session_key_prefix + key
return session_key in self.request.session
class DjangoCacheDataStorage:
"""Key/value storage using Django cache"""
def __init__(self, cache_name='default', **kwargs):
self._cache = cache
self._prefix = 'lti1p3_cache_'
def get_value(self, key):
"""Get value from cache"""
cache_key = self._prefix + key
return self._cache.get(cache_key)
def set_value(self, key, value, exp=3600):
"""Set value in cache with expiration"""
cache_key = self._prefix + key
return self._cache.set(cache_key, value, timeout=exp)
def check_value(self, key):
"""Check if value exists in cache"""
cache_key = self._prefix + key
return cache_key in self._cache
class DjangoToolConfig(ToolConfAbstract):
"""Tool configuration from Django models"""
def __init__(self, platforms_dict: Optional[Dict[str, Any]] = None):
"""
Initialize with platforms configuration
Args:
platforms_dict: Dictionary mapping platform_id to config
{
'https://moodle.example.com': {
'client_id': '...',
'auth_login_url': '...',
'auth_token_url': '...',
'key_set_url': '...',
'deployment_ids': [...],
}
}
"""
super().__init__()
self._config = platforms_dict or {}
def check_iss_has_one_client(self, iss):
"""Check if issuer has exactly one client"""
return iss in self._config and len([self._config[iss]]) == 1
def check_iss_has_many_clients(self, iss):
"""Check if issuer has multiple clients"""
# For now, we support one client per issuer
return False
def find_registration_by_issuer(self, iss, *args, **kwargs):
"""Find registration by issuer"""
if iss not in self._config:
return None
return self._config[iss]
def find_registration_by_params(self, iss, client_id, *args, **kwargs):
"""Find registration by issuer and client ID"""
if iss not in self._config:
return None
config = self._config[iss]
if config.get('client_id') == client_id:
return config
return None
def find_deployment(self, iss, deployment_id):
"""Find deployment by issuer and deployment ID"""
config = self.find_registration_by_issuer(iss)
if not config:
return None
deployment_ids = config.get('deployment_ids', [])
if deployment_id in deployment_ids:
return config
return None
def find_deployment_by_params(self, iss, deployment_id, client_id, *args, **kwargs):
"""Find deployment by parameters"""
config = self.find_registration_by_params(iss, client_id)
if not config:
return None
deployment_ids = config.get('deployment_ids', [])
if deployment_id in deployment_ids:
return config
return None
def get_jwks(self, iss, client_id=None):
"""Get JWKS from configuration"""
config = self.find_registration_by_params(iss, client_id) if client_id else self.find_registration_by_issuer(iss)
if not config:
return None
return config.get('key_set')
def get_iss(self):
"""Get all issuers"""
return list(self._config.keys())
@classmethod
def from_platform(cls, platform):
"""Create ToolConfig from LTIPlatform model instance"""
from .models import LTIPlatform
if isinstance(platform, LTIPlatform):
config = {platform.platform_id: platform.get_lti_config()}
return cls(config)
raise ValueError("Must provide LTIPlatform instance")
@classmethod
def from_all_platforms(cls):
"""Create ToolConfig with all active platforms"""
from .models import LTIPlatform
platforms = LTIPlatform.objects.filter(active=True)
config = {}
for platform in platforms:
config[platform.platform_id] = platform.get_lti_config()
return cls(config)

157
lti/admin.py Normal file
View File

@@ -0,0 +1,157 @@
"""
Django Admin for LTI models
"""
from django.contrib import admin
from django.utils.html import format_html
from .models import (
LTILaunchLog,
LTIPlatform,
LTIResourceLink,
LTIRoleMapping,
LTIUserMapping,
)
@admin.register(LTIPlatform)
class LTIPlatformAdmin(admin.ModelAdmin):
"""Admin for LTI Platforms (Moodle instances)"""
list_display = ['name', 'platform_id', 'client_id', 'active_badge', 'nrps_enabled', 'deep_linking_enabled', 'created_at']
list_filter = ['active', 'enable_nrps', 'enable_deep_linking', 'created_at']
search_fields = ['name', 'platform_id', 'client_id']
readonly_fields = ['created_at', 'updated_at', 'key_set_updated']
fieldsets = (
('Basic Information', {'fields': ('name', 'platform_id', 'client_id', 'active')}),
('OIDC Endpoints', {'fields': ('auth_login_url', 'auth_token_url', 'auth_audience')}),
('JWK Configuration', {'fields': ('key_set_url', 'key_set', 'key_set_updated'), 'classes': ('collapse',)}),
('Deployment & Features', {'fields': ('deployment_ids', 'enable_nrps', 'enable_deep_linking')}),
('Auto-Provisioning Settings', {'fields': ('auto_create_categories', 'auto_create_users', 'auto_sync_roles', 'remove_from_groups_on_unenroll')}),
('Timestamps', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}),
)
def active_badge(self, obj):
"""Display active status as badge"""
if obj.active:
return format_html('<span style="color: green;">✓ Active</span>')
return format_html('<span style="color: red;">✗ Inactive</span>')
active_badge.short_description = 'Status'
def nrps_enabled(self, obj):
return '' if obj.enable_nrps else ''
nrps_enabled.short_description = 'NRPS'
def deep_linking_enabled(self, obj):
return '' if obj.enable_deep_linking else ''
deep_linking_enabled.short_description = 'Deep Link'
@admin.register(LTIResourceLink)
class LTIResourceLinkAdmin(admin.ModelAdmin):
"""Admin for LTI Resource Links"""
list_display = ['context_title', 'platform', 'category_link', 'rbac_group_link', 'launch_count', 'last_launch']
list_filter = ['platform', 'created_at', 'last_launch']
search_fields = ['context_id', 'context_title', 'resource_link_id']
readonly_fields = ['created_at', 'last_launch', 'launch_count']
fieldsets = (
('Platform', {'fields': ('platform',)}),
('Context (Course)', {'fields': ('context_id', 'context_title', 'context_label')}),
('Resource Link', {'fields': ('resource_link_id', 'resource_link_title')}),
('MediaCMS Mappings', {'fields': ('category', 'rbac_group', 'media')}),
('Metrics', {'fields': ('launch_count', 'last_launch', 'created_at'), 'classes': ('collapse',)}),
)
def category_link(self, obj):
if obj.category:
return format_html('<a href="/admin/files/category/{}/change/">{}</a>', obj.category.id, obj.category.title)
return '-'
category_link.short_description = 'Category'
def rbac_group_link(self, obj):
if obj.rbac_group:
return format_html('<a href="/admin/rbac/rbacgroup/{}/change/">{}</a>', obj.rbac_group.id, obj.rbac_group.name)
return '-'
rbac_group_link.short_description = 'RBAC Group'
@admin.register(LTIUserMapping)
class LTIUserMappingAdmin(admin.ModelAdmin):
"""Admin for LTI User Mappings"""
list_display = ['user_link', 'lti_user_id', 'platform', 'email', 'last_login']
list_filter = ['platform', 'created_at', 'last_login']
search_fields = ['lti_user_id', 'user__username', 'user__email', 'email']
readonly_fields = ['created_at', 'last_login']
fieldsets = (
('Mapping', {'fields': ('platform', 'lti_user_id', 'user')}),
('User Info (Cached)', {'fields': ('email', 'given_name', 'family_name', 'name')}),
('Timestamps', {'fields': ('created_at', 'last_login')}),
)
def user_link(self, obj):
return format_html('<a href="/admin/users/user/{}/change/">{}</a>', obj.user.id, obj.user.username)
user_link.short_description = 'MediaCMS User'
@admin.register(LTIRoleMapping)
class LTIRoleMappingAdmin(admin.ModelAdmin):
"""Admin for LTI Role Mappings"""
list_display = ['lti_role', 'platform', 'global_role', 'group_role']
list_filter = ['platform', 'global_role', 'group_role']
search_fields = ['lti_role']
fieldsets = (
('LTI Role', {'fields': ('platform', 'lti_role')}),
('MediaCMS Roles', {'fields': ('global_role', 'group_role'), 'description': 'Map this LTI role to MediaCMS global and group roles'}),
)
@admin.register(LTILaunchLog)
class LTILaunchLogAdmin(admin.ModelAdmin):
"""Admin for LTI Launch Logs"""
list_display = ['created_at', 'platform', 'user_link', 'launch_type', 'success_badge', 'ip_address']
list_filter = ['success', 'launch_type', 'platform', 'created_at']
search_fields = ['user__username', 'ip_address', 'error_message']
readonly_fields = ['created_at', 'claims']
date_hierarchy = 'created_at'
fieldsets = (
('Launch Info', {'fields': ('platform', 'user', 'resource_link', 'launch_type', 'success', 'ip_address', 'created_at')}),
('Error Details', {'fields': ('error_message',), 'classes': ('collapse',)}),
('Claims Data', {'fields': ('claims',), 'classes': ('collapse',)}),
)
def success_badge(self, obj):
if obj.success:
return format_html('<span style="color: green;">✓ Success</span>')
return format_html('<span style="color: red;">✗ Failed</span>')
success_badge.short_description = 'Status'
def user_link(self, obj):
if obj.user:
return format_html('<a href="/admin/users/user/{}/change/">{}</a>', obj.user.id, obj.user.username)
return '-'
user_link.short_description = 'User'
def has_add_permission(self, request):
"""Disable manual creation of launch logs"""
return False
def has_change_permission(self, request, obj=None):
"""Make launch logs read-only"""
return False

12
lti/apps.py Normal file
View File

@@ -0,0 +1,12 @@
from django.apps import AppConfig
class LtiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'lti'
verbose_name = 'LTI 1.3 Integration'
def ready(self):
"""Import signal handlers when app is ready"""
# Import any signals here if needed in the future
pass

165
lti/deep_linking.py Normal file
View File

@@ -0,0 +1,165 @@
"""
LTI Deep Linking 2.0 for MediaCMS
Allows instructors to select media from MediaCMS library and embed in Moodle courses
"""
import logging
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from files.models import Media
from .models import LTIPlatform
logger = logging.getLogger(__name__)
@method_decorator(login_required, name='dispatch')
class SelectMediaView(View):
"""
UI for instructors to select media for deep linking
Flow: Instructor clicks "Add MediaCMS" in Moodle → Deep link launch →
This view → Instructor selects media → Return to Moodle
"""
def get(self, request):
"""Display media selection interface"""
# Get deep link session data
deep_link_data = request.session.get('lti_deep_link')
if not deep_link_data:
return JsonResponse({'error': 'Invalid session', 'message': 'No deep linking session data found'}, status=400)
# Get accessible media for user
user = request.user
if getattr(settings, 'USE_RBAC', False):
# Get categories user has access to
categories = user.get_rbac_categories_as_member()
media_queryset = Media.objects.filter(listable=True, category__in=categories)
else:
# Get all public media
media_queryset = Media.objects.filter(listable=True, state='public')
# Optionally filter by user's own media
show_my_media_only = request.GET.get('my_media_only', 'false').lower() == 'true'
if show_my_media_only:
media_queryset = media_queryset.filter(user=user)
# Order by recent
media_list = media_queryset.order_by('-add_date')[:100] # Limit to 100 for performance
context = {
'media_list': media_list,
'deep_link_data': deep_link_data,
'show_my_media_only': show_my_media_only,
}
return render(request, 'lti/select_media.html', context)
@method_decorator(csrf_exempt)
def post(self, request):
"""Return selected media as deep linking content items"""
# Get deep link session data
deep_link_data = request.session.get('lti_deep_link')
if not deep_link_data:
return JsonResponse({'error': 'Invalid session'}, status=400)
# Get selected media IDs
selected_ids = request.POST.getlist('media_ids[]')
if not selected_ids:
return JsonResponse({'error': 'No media selected'}, status=400)
# Build content items
content_items = []
for media_id in selected_ids:
try:
media = Media.objects.get(id=media_id)
# Build embed URL
embed_url = request.build_absolute_uri(reverse('lti:embed_media', args=[media.friendly_token]))
content_item = {
'type': 'ltiResourceLink',
'title': media.title,
'url': embed_url,
'custom': {
'media_friendly_token': media.friendly_token,
},
}
# Add thumbnail if available
if media.thumbnail_url:
content_item['thumbnail'] = {'url': media.thumbnail_url, 'width': 344, 'height': 194}
# Add iframe configuration
content_item['iframe'] = {'width': 960, 'height': 540}
content_items.append(content_item)
except Media.DoesNotExist:
logger.warning(f"Media {media_id} not found during deep linking")
continue
if not content_items:
return JsonResponse({'error': 'No valid media found'}, status=400)
# Create deep linking JWT response
# Note: This is a simplified version
# Full implementation would use PyLTI1p3's DeepLink response builder
jwt_response = self.create_deep_link_jwt(deep_link_data, content_items, request)
# Return auto-submit form that posts JWT back to Moodle
context = {
'return_url': deep_link_data['deep_link_return_url'],
'jwt': jwt_response,
}
return render(request, 'lti/deep_link_return.html', context)
def create_deep_link_jwt(self, deep_link_data, content_items, request):
"""
Create JWT response for deep linking
This is a placeholder - full implementation would use PyLTI1p3
"""
# TODO: Implement proper JWT creation using PyLTI1p3's DeepLink.output_response_form()
# For now, return a placeholder
try:
from .adapters import DjangoToolConfig
platform_id = deep_link_data['platform_id']
platform = LTIPlatform.objects.get(id=platform_id)
DjangoToolConfig.from_platform(platform)
# This requires the full message launch object to create properly
# For now, we'll create a simple response
# In a real implementation, you would:
# 1. Get the MessageLaunch object from session
# 2. Call launch.get_deep_link()
# 3. Call deep_link.output_response_form(content_items)
logger.warning("Deep linking JWT creation not fully implemented")
return "JWT_TOKEN_PLACEHOLDER"
except Exception as e:
logger.error(f"Error creating deep link JWT: {str(e)}", exc_info=True)
return "ERROR_CREATING_JWT"

388
lti/handlers.py Normal file
View File

@@ -0,0 +1,388 @@
"""
LTI Launch Handlers for User and Context Provisioning
Provides functions to:
- Create/update MediaCMS users from LTI launches
- Create/update categories and RBAC groups for courses
- Apply role mappings from LTI to MediaCMS
- Create and manage LTI sessions
"""
import hashlib
import logging
from django.conf import settings
from django.contrib.auth import login
from django.utils import timezone
from files.models import Category
from rbac.models import RBACGroup, RBACMembership
from users.models import User
from .models import LTIResourceLink, LTIRoleMapping, LTIUserMapping
logger = logging.getLogger(__name__)
# Default LTI role mappings
DEFAULT_LTI_ROLE_MAPPINGS = {
'Instructor': {'global_role': 'advancedUser', 'group_role': 'manager'},
'TeachingAssistant': {'global_role': 'user', 'group_role': 'contributor'},
'Learner': {'global_role': 'user', 'group_role': 'member'},
'Student': {'global_role': 'user', 'group_role': 'member'},
'Administrator': {'global_role': 'manager', 'group_role': 'manager'},
'Faculty': {'global_role': 'advancedUser', 'group_role': 'manager'},
}
def provision_lti_user(platform, claims):
"""
Provision MediaCMS user from LTI launch claims
Args:
platform: LTIPlatform instance
claims: Dict of LTI launch claims
Returns:
User instance
Pattern: Similar to saml_auth.adapter.perform_user_actions()
"""
lti_user_id = claims.get('sub')
if not lti_user_id:
raise ValueError("Missing 'sub' claim in LTI launch")
email = claims.get('email', '')
given_name = claims.get('given_name', '')
family_name = claims.get('family_name', '')
name = claims.get('name', f"{given_name} {family_name}").strip()
# Check for existing mapping
mapping = LTIUserMapping.objects.filter(platform=platform, lti_user_id=lti_user_id).select_related('user').first()
if mapping:
# Update existing user
user = mapping.user
update_fields = []
# Update email if changed and not empty
if email and user.email != email:
user.email = email
update_fields.append('email')
# Update name fields if changed
if given_name and user.first_name != given_name:
user.first_name = given_name
update_fields.append('first_name')
if family_name and user.last_name != family_name:
user.last_name = family_name
update_fields.append('last_name')
if name and user.name != name:
user.name = name
update_fields.append('name')
if update_fields:
user.save(update_fields=update_fields)
# Update mapping cache
if email and mapping.email != email:
mapping.email = email
mapping.save(update_fields=['email'])
logger.info(f"Updated LTI user: {user.username} (platform: {platform.name})")
else:
# Create new user
username = generate_username_from_lti(lti_user_id, email, given_name, family_name)
# Check if username already exists
if User.objects.filter(username=username).exists():
# Add random suffix
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)
# Mark email as verified via allauth
if email:
try:
from allauth.account.models import EmailAddress
EmailAddress.objects.create(user=user, email=email, verified=True, primary=True)
except Exception as e:
logger.warning(f"Could not create EmailAddress for LTI user: {e}")
# Create mapping
LTIUserMapping.objects.create(platform=platform, lti_user_id=lti_user_id, user=user, email=email, given_name=given_name, family_name=family_name, name=name)
logger.info(f"Created new LTI user: {user.username} (platform: {platform.name})")
return user
def generate_username_from_lti(lti_user_id, email, given_name, family_name):
"""Generate a username from LTI user info"""
# Try email username
if email and '@' in email:
username = email.split('@')[0]
# Clean up username - only alphanumeric, underscore, hyphen
username = ''.join(c if c.isalnum() or c in '_-' else '_' for c in username)
if len(username) >= 4:
return username[:30] # Max 30 chars
# Try first.last
if given_name and family_name:
username = f"{given_name}.{family_name}".lower()
username = ''.join(c if c.isalnum() or c in '_-.' else '_' for c in username)
if len(username) >= 4:
return username[:30]
# Use hashed LTI user ID as fallback
user_hash = hashlib.md5(lti_user_id.encode()).hexdigest()[:10]
return f"lti_user_{user_hash}"
def provision_lti_context(platform, claims, resource_link_id):
"""
Provision MediaCMS category and RBAC group for LTI context (course)
Args:
platform: LTIPlatform instance
claims: Dict of LTI launch claims
resource_link_id: Resource link ID
Returns:
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_id = context.get('id')
if not context_id:
raise ValueError("Missing context ID in LTI launch")
context_title = context.get('title', '')
context_label = context.get('label', '')
# Unique identifier for this course
uid = f"lti_{platform.id}_{context_id}"
# Get or create category
category, created = Category.objects.get_or_create(
uid=uid,
defaults={
'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, # New field!
'lti_platform': platform,
'lti_context_id': context_id,
},
)
if created:
logger.info(f"Created category for LTI context: {category.title} (uid: {uid})")
else:
# Update title if changed
if context_title and category.title != context_title:
category.title = context_title
category.save(update_fields=['title'])
# Get or create RBAC group
rbac_group, created = RBACGroup.objects.get_or_create(
uid=uid,
defaults={
'name': f"{context_title or context_label} ({platform.name})",
'description': f"LTI course group from {platform.name}",
},
)
if created:
logger.info(f"Created RBAC group for LTI context: {rbac_group.name}")
# Link category to RBAC group
if category not in rbac_group.categories.all():
rbac_group.categories.add(category)
logger.info(f"Linked category {category.title} to RBAC group {rbac_group.name}")
# Get or create resource link
resource_link, created = LTIResourceLink.objects.get_or_create(
platform=platform,
context_id=context_id,
resource_link_id=resource_link_id,
defaults={
'context_title': context_title,
'context_label': context_label,
'category': category,
'rbac_group': rbac_group,
},
)
# Update launch metrics
resource_link.launch_count += 1
resource_link.last_launch = timezone.now()
resource_link.save(update_fields=['launch_count', 'last_launch'])
if not created:
# Update relationships if needed
if resource_link.category != category:
resource_link.category = category
resource_link.save(update_fields=['category'])
if resource_link.rbac_group != rbac_group:
resource_link.rbac_group = rbac_group
resource_link.save(update_fields=['rbac_group'])
return category, rbac_group, resource_link
def apply_lti_roles(user, platform, lti_roles, rbac_group):
"""
Apply role mappings from LTI to MediaCMS
Args:
user: User instance
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 = []
# Extract short role names from URIs
# e.g., "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor" -> "Instructor"
short_roles = []
for role in lti_roles:
if '#' in role:
short_roles.append(role.split('#')[-1])
elif '/' in role:
short_roles.append(role.split('/')[-1])
else:
short_roles.append(role)
# Get custom role mappings from database
custom_mappings = {}
for mapping in LTIRoleMapping.objects.filter(platform=platform):
custom_mappings[mapping.lti_role] = {
'global_role': mapping.global_role,
'group_role': mapping.group_role,
}
# Combine default and custom mappings (custom takes precedence)
all_mappings = {**DEFAULT_LTI_ROLE_MAPPINGS, **custom_mappings}
# Determine highest privilege global role
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)
# Apply global role if auto_sync_roles is enabled
if platform.auto_sync_roles:
user.set_role_from_mapping(global_role)
logger.info(f"Applied global role '{global_role}' to user {user.username}")
# Determine group role
group_role = 'member'
for role in short_roles:
if role in all_mappings:
role_group = all_mappings[role].get('group_role')
if role_group:
group_role = get_higher_privilege_group(group_role, role_group)
# Create or update RBAC membership
membership, created = RBACMembership.objects.update_or_create(user=user, rbac_group=rbac_group, defaults={'role': group_role})
if created:
logger.info(f"Added user {user.username} to RBAC group {rbac_group.name} as {group_role}")
else:
logger.info(f"Updated user {user.username} in RBAC group {rbac_group.name} to {group_role}")
return global_role, group_role
def get_higher_privilege_global(role1, role2):
"""Return the higher privilege global role"""
privilege_order = ['user', 'advancedUser', 'editor', 'manager', 'admin']
try:
index1 = privilege_order.index(role1)
index2 = privilege_order.index(role2)
return privilege_order[max(index1, index2)]
except ValueError:
return role2 # Default to role2 if role1 is unknown
def get_higher_privilege_group(role1, role2):
"""Return the higher privilege group role"""
privilege_order = ['member', 'contributor', 'manager']
try:
index1 = privilege_order.index(role1)
index2 = privilege_order.index(role2)
return privilege_order[max(index1, index2)]
except ValueError:
return role2 # Default to role2 if role1 is unknown
def create_lti_session(request, user, launch_data, platform):
"""
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
"""
# Django login (creates session in Redis)
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
# Extract key context info
context = launch_data.get_launch_data().get('https://purl.imsglobal.org/spec/lti/claim/context', {})
resource_link = launch_data.get_launch_data().get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {})
roles = launch_data.get_launch_data().get('https://purl.imsglobal.org/spec/lti/claim/roles', [])
# Store LTI context in session
request.session['lti_session'] = {
'platform_id': platform.id,
'platform_name': platform.name,
'context_id': context.get('id'),
'context_title': context.get('title'),
'resource_link_id': resource_link.get('id'),
'roles': roles,
'launch_time': timezone.now().isoformat(),
}
# Session timeout from settings or default 1 hour
timeout = getattr(settings, 'LTI_SESSION_TIMEOUT', 3600)
request.session.set_expiry(timeout)
logger.info(f"Created LTI session for user {user.username} (expires in {timeout}s)")
return True
def validate_lti_session(request):
"""
Validate that an LTI session exists and is valid
Returns:
Dict of LTI session data or None
"""
lti_session = request.session.get('lti_session')
if not lti_session:
return None
# Check if session has expired (Django handles this, but double-check)
if not request.user.is_authenticated:
return None
return lti_session

View File

@@ -0,0 +1,200 @@
# Generated by Django 5.2.6 on 2025-12-24 15:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('rbac', '0003_alter_rbacgroup_members'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='LTIPlatform',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Platform name (e.g., 'Moodle Production')", max_length=255, unique=True)),
('platform_id', models.URLField(help_text="Platform's issuer URL (iss claim, e.g., https://moodle.example.com)")),
('client_id', models.CharField(help_text='Client ID provided by the platform', max_length=255)),
('auth_login_url', models.URLField(help_text='OIDC authentication endpoint URL')),
('auth_token_url', models.URLField(help_text='OAuth2 token endpoint URL')),
('auth_audience', models.URLField(blank=True, help_text='OAuth2 audience (optional)', null=True)),
('key_set_url', models.URLField(help_text="Platform's public JWK Set URL")),
('key_set', models.JSONField(blank=True, help_text='Cached JWK Set (auto-fetched)', null=True)),
('key_set_updated', models.DateTimeField(blank=True, help_text='Last time JWK Set was fetched', null=True)),
('deployment_ids', models.JSONField(default=list, help_text='List of deployment IDs for this platform')),
('enable_nrps', models.BooleanField(default=True, help_text='Enable Names and Role Provisioning Service')),
('enable_deep_linking', models.BooleanField(default=True, help_text='Enable Deep Linking 2.0')),
('auto_create_categories', models.BooleanField(default=True, help_text='Automatically create categories for courses')),
('auto_create_users', models.BooleanField(default=True, help_text='Automatically create users on first launch')),
('auto_sync_roles', models.BooleanField(default=True, help_text='Automatically sync user roles from LTI')),
('remove_from_groups_on_unenroll', models.BooleanField(default=False, help_text="Remove users from RBAC groups when they're no longer in the course")),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('active', models.BooleanField(default=True, help_text='Whether this platform is currently active')),
],
options={
'verbose_name': 'LTI Platform',
'verbose_name_plural': 'LTI Platforms',
'unique_together': {('platform_id', 'client_id')},
},
),
migrations.CreateModel(
name='LTIResourceLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('context_id', models.CharField(db_index=True, help_text='LTI context ID (typically course ID)', max_length=255)),
('context_title', models.CharField(blank=True, help_text='Course title', max_length=255)),
('context_label', models.CharField(blank=True, help_text='Course short name/code', max_length=100)),
('resource_link_id', models.CharField(db_index=True, help_text='LTI resource link ID', max_length=255)),
('resource_link_title', models.CharField(blank=True, help_text='Resource link title', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_launch', models.DateTimeField(auto_now=True)),
('launch_count', models.IntegerField(default=0, help_text='Number of times this resource has been launched')),
(
'category',
models.ForeignKey(
blank=True, help_text='Mapped MediaCMS category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lti_resource_links', to='files.category'
),
),
(
'media',
models.ForeignKey(
blank=True, help_text='Specific media for embedded links', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lti_resource_links', to='files.media'
),
),
('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resource_links', to='lti.ltiplatform')),
(
'rbac_group',
models.ForeignKey(
blank=True, help_text='RBAC group for course members', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lti_resource_links', to='rbac.rbacgroup'
),
),
],
options={
'verbose_name': 'LTI Resource Link',
'verbose_name_plural': 'LTI Resource Links',
},
),
migrations.CreateModel(
name='LTILaunchLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('launch_type', models.CharField(choices=[('resource_link', 'Resource Link Launch'), ('deep_linking', 'Deep Linking')], default='resource_link', max_length=50)),
('success', models.BooleanField(db_index=True, default=True, help_text='Whether the launch was successful')),
('error_message', models.TextField(blank=True, help_text='Error message if launch failed')),
('claims', models.JSONField(help_text='Sanitized LTI claims from the launch')),
('ip_address', models.GenericIPAddressField(blank=True, help_text='IP address of the user', null=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
(
'user',
models.ForeignKey(
blank=True,
help_text='MediaCMS user (null if launch failed before user creation)',
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='lti_launch_logs',
to=settings.AUTH_USER_MODEL,
),
),
('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='launch_logs', to='lti.ltiplatform')),
('resource_link', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='launch_logs', to='lti.ltiresourcelink')),
],
options={
'verbose_name': 'LTI Launch Log',
'verbose_name_plural': 'LTI Launch Logs',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='LTIRoleMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lti_role', models.CharField(help_text="LTI role URI or short name (e.g., 'Instructor', 'Learner')", max_length=255)),
(
'global_role',
models.CharField(
blank=True,
choices=[
('user', 'Authenticated User'),
('advancedUser', 'Advanced User'),
('editor', 'MediaCMS Editor'),
('manager', 'MediaCMS Manager'),
('admin', 'MediaCMS Administrator'),
],
help_text='MediaCMS global role to assign',
max_length=20,
),
),
(
'group_role',
models.CharField(blank=True, choices=[('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')], help_text='RBAC group role to assign', max_length=20),
),
('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_mappings', to='lti.ltiplatform')),
],
options={
'verbose_name': 'LTI Role Mapping',
'verbose_name_plural': 'LTI Role Mappings',
},
),
migrations.CreateModel(
name='LTIUserMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lti_user_id', models.CharField(db_index=True, help_text="LTI 'sub' claim (unique user identifier from platform)", max_length=255)),
('email', models.EmailField(blank=True, max_length=254)),
('given_name', models.CharField(blank=True, max_length=100)),
('family_name', models.CharField(blank=True, max_length=100)),
('name', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_login', models.DateTimeField(auto_now=True)),
('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_mappings', to='lti.ltiplatform')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lti_mappings', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'LTI User Mapping',
'verbose_name_plural': 'LTI User Mappings',
},
),
migrations.AddIndex(
model_name='ltiresourcelink',
index=models.Index(fields=['platform', 'context_id'], name='lti_ltireso_platfor_4a3f27_idx'),
),
migrations.AddIndex(
model_name='ltiresourcelink',
index=models.Index(fields=['context_id'], name='lti_ltireso_context_c6f9e2_idx'),
),
migrations.AlterUniqueTogether(
name='ltiresourcelink',
unique_together={('platform', 'context_id', 'resource_link_id')},
),
migrations.AddIndex(
model_name='ltilaunchlog',
index=models.Index(fields=['-created_at'], name='lti_ltilaun_created_94c574_idx'),
),
migrations.AddIndex(
model_name='ltilaunchlog',
index=models.Index(fields=['platform', 'user'], name='lti_ltilaun_platfor_5240bf_idx'),
),
migrations.AlterUniqueTogether(
name='ltirolemapping',
unique_together={('platform', 'lti_role')},
),
migrations.AddIndex(
model_name='ltiusermapping',
index=models.Index(fields=['platform', 'lti_user_id'], name='lti_ltiuser_platfor_9c70bb_idx'),
),
migrations.AddIndex(
model_name='ltiusermapping',
index=models.Index(fields=['user'], name='lti_ltiuser_user_id_b06d01_idx'),
),
migrations.AlterUniqueTogether(
name='ltiusermapping',
unique_together={('platform', 'lti_user_id')},
),
]

View File

185
lti/models.py Executable file
View File

@@ -0,0 +1,185 @@
from django.db import models
class LTIPlatform(models.Model):
"""LTI 1.3 Platform (Moodle instance) configuration"""
# Basic identification
name = models.CharField(max_length=255, unique=True, help_text="Platform name (e.g., 'Moodle Production')")
platform_id = models.URLField(help_text="Platform's issuer URL (iss claim, e.g., https://moodle.example.com)")
client_id = models.CharField(max_length=255, help_text="Client ID provided by the platform")
# OIDC endpoints
auth_login_url = models.URLField(help_text="OIDC authentication endpoint URL")
auth_token_url = models.URLField(help_text="OAuth2 token endpoint URL")
auth_audience = models.URLField(blank=True, null=True, help_text="OAuth2 audience (optional)")
# JWK configuration
key_set_url = models.URLField(help_text="Platform's public JWK Set URL")
key_set = models.JSONField(blank=True, null=True, help_text="Cached JWK Set (auto-fetched)")
key_set_updated = models.DateTimeField(null=True, blank=True, help_text="Last time JWK Set was fetched")
# Deployment & features
deployment_ids = models.JSONField(default=list, help_text="List of deployment IDs for this platform")
enable_nrps = models.BooleanField(default=True, help_text="Enable Names and Role Provisioning Service")
enable_deep_linking = models.BooleanField(default=True, help_text="Enable Deep Linking 2.0")
# Auto-provisioning settings
auto_create_categories = models.BooleanField(default=True, help_text="Automatically create categories for courses")
auto_create_users = models.BooleanField(default=True, help_text="Automatically create users on first launch")
auto_sync_roles = models.BooleanField(default=True, help_text="Automatically sync user roles from LTI")
remove_from_groups_on_unenroll = models.BooleanField(default=False, help_text="Remove users from RBAC groups when they're no longer in the course")
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
active = models.BooleanField(default=True, help_text="Whether this platform is currently active")
class Meta:
verbose_name = 'LTI Platform'
verbose_name_plural = 'LTI Platforms'
unique_together = [['platform_id', 'client_id']]
def __str__(self):
return f"{self.name} ({self.platform_id})"
def get_lti_config(self):
"""Return configuration dict for PyLTI1p3"""
return {
'platform_id': self.platform_id,
'client_id': self.client_id,
'auth_login_url': self.auth_login_url,
'auth_token_url': self.auth_token_url,
'auth_audience': self.auth_audience,
'key_set_url': self.key_set_url,
'key_set': self.key_set,
'deployment_ids': self.deployment_ids,
}
class LTIResourceLink(models.Model):
"""Specific LTI resource link (e.g., MediaCMS in a Moodle course)"""
platform = models.ForeignKey(LTIPlatform, on_delete=models.CASCADE, related_name='resource_links')
# LTI context (course)
context_id = models.CharField(max_length=255, db_index=True, help_text="LTI context ID (typically course ID)")
context_title = models.CharField(max_length=255, blank=True, help_text="Course title")
context_label = models.CharField(max_length=100, blank=True, help_text="Course short name/code")
# Resource link
resource_link_id = models.CharField(max_length=255, db_index=True, help_text="LTI resource link ID")
resource_link_title = models.CharField(max_length=255, blank=True, help_text="Resource link title")
# MediaCMS mappings
category = models.ForeignKey('files.Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='lti_resource_links', help_text="Mapped MediaCMS category")
media = models.ForeignKey('files.Media', on_delete=models.SET_NULL, null=True, blank=True, related_name='lti_resource_links', help_text="Specific media for embedded links")
rbac_group = models.ForeignKey('rbac.RBACGroup', on_delete=models.SET_NULL, null=True, blank=True, related_name='lti_resource_links', help_text="RBAC group for course members")
# Metrics
created_at = models.DateTimeField(auto_now_add=True)
last_launch = models.DateTimeField(auto_now=True)
launch_count = models.IntegerField(default=0, help_text="Number of times this resource has been launched")
class Meta:
verbose_name = 'LTI Resource Link'
verbose_name_plural = 'LTI Resource Links'
unique_together = [['platform', 'context_id', 'resource_link_id']]
indexes = [
models.Index(fields=['platform', 'context_id']),
models.Index(fields=['context_id']),
]
def __str__(self):
return f"{self.context_title or self.context_id} - {self.resource_link_title or self.resource_link_id}"
class LTIUserMapping(models.Model):
"""Maps LTI user identities (sub claim) to MediaCMS users"""
platform = models.ForeignKey(LTIPlatform, on_delete=models.CASCADE, related_name='user_mappings')
lti_user_id = models.CharField(max_length=255, db_index=True, help_text="LTI 'sub' claim (unique user identifier from platform)")
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='lti_mappings')
# User info from LTI (cached)
email = models.EmailField(blank=True)
given_name = models.CharField(max_length=100, blank=True)
family_name = models.CharField(max_length=100, blank=True)
name = models.CharField(max_length=255, blank=True)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
last_login = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'LTI User Mapping'
verbose_name_plural = 'LTI User Mappings'
unique_together = [['platform', 'lti_user_id']]
indexes = [
models.Index(fields=['platform', 'lti_user_id']),
models.Index(fields=['user']),
]
def __str__(self):
return f"{self.user.username} ({self.platform.name})"
class LTIRoleMapping(models.Model):
"""Maps LTI institutional roles to MediaCMS roles"""
GLOBAL_ROLE_CHOICES = [('user', 'Authenticated User'), ('advancedUser', 'Advanced User'), ('editor', 'MediaCMS Editor'), ('manager', 'MediaCMS Manager'), ('admin', 'MediaCMS Administrator')]
GROUP_ROLE_CHOICES = [('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')]
platform = models.ForeignKey(LTIPlatform, on_delete=models.CASCADE, related_name='role_mappings')
lti_role = models.CharField(max_length=255, help_text="LTI role URI or short name (e.g., 'Instructor', 'Learner')")
# Global role (optional)
global_role = models.CharField(max_length=20, blank=True, choices=GLOBAL_ROLE_CHOICES, help_text="MediaCMS global role to assign")
# Group role for RBAC
group_role = models.CharField(max_length=20, blank=True, choices=GROUP_ROLE_CHOICES, help_text="RBAC group role to assign")
class Meta:
verbose_name = 'LTI Role Mapping'
verbose_name_plural = 'LTI Role Mappings'
unique_together = [['platform', 'lti_role']]
def __str__(self):
return f"{self.lti_role}{self.global_role or 'none'}/{self.group_role or 'none'} ({self.platform.name})"
class LTILaunchLog(models.Model):
"""Audit log for LTI launches"""
LAUNCH_TYPE_CHOICES = [
('resource_link', 'Resource Link Launch'),
('deep_linking', 'Deep Linking'),
]
platform = models.ForeignKey(LTIPlatform, on_delete=models.CASCADE, related_name='launch_logs')
user = models.ForeignKey('users.User', on_delete=models.CASCADE, null=True, blank=True, related_name='lti_launch_logs', help_text="MediaCMS user (null if launch failed before user creation)")
resource_link = models.ForeignKey(LTIResourceLink, on_delete=models.SET_NULL, null=True, blank=True, related_name='launch_logs')
launch_type = models.CharField(max_length=50, choices=LAUNCH_TYPE_CHOICES, default='resource_link')
success = models.BooleanField(default=True, db_index=True, help_text="Whether the launch was successful")
error_message = models.TextField(blank=True, help_text="Error message if launch failed")
claims = models.JSONField(help_text="Sanitized LTI claims from the launch")
ip_address = models.GenericIPAddressField(null=True, blank=True, help_text="IP address of the user")
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
verbose_name = 'LTI Launch Log'
verbose_name_plural = 'LTI Launch Logs'
ordering = ['-created_at']
indexes = [
models.Index(fields=['-created_at']),
models.Index(fields=['platform', 'user']),
]
def __str__(self):
status = "" if self.success else ""
user_str = self.user.username if self.user else "Unknown"
return f"{status} {user_str} @ {self.platform.name} ({self.created_at.strftime('%Y-%m-%d %H:%M')})"

42
lti/serializers.py Normal file
View File

@@ -0,0 +1,42 @@
"""
REST API Serializers for LTI
Currently minimal - can be expanded for API endpoints if needed
"""
from rest_framework import serializers
from .models import LTIPlatform, LTIResourceLink, LTIUserMapping
class LTIPlatformSerializer(serializers.ModelSerializer):
"""Serializer for LTI Platform"""
class Meta:
model = LTIPlatform
fields = ['id', 'name', 'platform_id', 'active', 'enable_nrps', 'enable_deep_linking']
read_only_fields = ['id']
class LTIResourceLinkSerializer(serializers.ModelSerializer):
"""Serializer for LTI Resource Link"""
platform_name = serializers.CharField(source='platform.name', read_only=True)
category_title = serializers.CharField(source='category.title', read_only=True)
class Meta:
model = LTIResourceLink
fields = ['id', 'platform', 'platform_name', 'context_id', 'context_title', 'category', 'category_title', 'launch_count', 'last_launch']
read_only_fields = ['id', 'launch_count', 'last_launch']
class LTIUserMappingSerializer(serializers.ModelSerializer):
"""Serializer for LTI User Mapping"""
username = serializers.CharField(source='user.username', read_only=True)
platform_name = serializers.CharField(source='platform.name', read_only=True)
class Meta:
model = LTIUserMapping
fields = ['id', 'platform', 'platform_name', 'lti_user_id', 'user', 'username', 'email', 'name', 'last_login']
read_only_fields = ['id', 'last_login']

196
lti/services.py Normal file
View File

@@ -0,0 +1,196 @@
"""
LTI Names and Role Provisioning Service (NRPS) Client
Fetches course membership from Moodle via NRPS and syncs to MediaCMS RBAC groups
"""
import logging
from django.utils import timezone
from pylti1p3.names_roles import NamesRolesProvisioningService
from users.models import User
from .handlers import apply_lti_roles, generate_username_from_lti
from .models import LTIUserMapping
logger = logging.getLogger(__name__)
class LTINRPSClient:
"""Client for Names and Role Provisioning Service"""
def __init__(self, platform, launch_claims):
"""
Initialize NRPS client
Args:
platform: LTIPlatform instance
launch_claims: Dict of LTI launch claims containing NRPS endpoint
"""
self.platform = platform
self.launch_claims = launch_claims
# Extract NRPS claim
self.nrps_claim = launch_claims.get('https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice')
def can_sync(self):
"""Check if NRPS sync is available"""
if not self.platform.enable_nrps:
logger.warning(f"NRPS disabled for platform {self.platform.name}")
return False
if not self.nrps_claim:
logger.warning("NRPS claim missing in launch data")
return False
service_url = self.nrps_claim.get('context_memberships_url')
if not service_url:
logger.warning("NRPS context_memberships_url missing")
return False
return True
def fetch_members(self):
"""
Fetch all course members from Moodle via NRPS
Returns:
List of member dicts with keys: user_id, name, email, roles, etc.
"""
if not self.can_sync():
return []
try:
service_url = self.nrps_claim.get('context_memberships_url')
# Use PyLTI1p3's NRPS service
# Note: This requires proper configuration in the tool config
from .adapters import DjangoToolConfig
tool_config = DjangoToolConfig.from_platform(self.platform)
nrps = NamesRolesProvisioningService(tool_config, service_url)
# Fetch members
members = nrps.get_members()
logger.info(f"Fetched {len(members)} members from NRPS for platform {self.platform.name}")
return members
except Exception as e:
logger.error(f"NRPS fetch error: {str(e)}", exc_info=True)
return []
def sync_members_to_rbac_group(self, rbac_group):
"""
Sync NRPS members to MediaCMS RBAC group
Args:
rbac_group: RBACGroup instance
Returns:
Dict with sync results
"""
members = self.fetch_members()
if not members:
logger.warning("No members fetched from NRPS")
return {'synced': 0, 'removed': 0, 'synced_at': timezone.now().isoformat()}
processed_users = set()
synced_count = 0
for member in members:
try:
user = self._get_or_create_user_from_nrps(member)
if not user:
continue
processed_users.add(user.id)
# Get roles from member
roles = member.get('roles', [])
# Apply role mapping
apply_lti_roles(user, self.platform, roles, rbac_group)
synced_count += 1
except Exception as e:
logger.error(f"Error syncing NRPS member {member.get('user_id')}: {str(e)}")
continue
# Remove unenrolled users if configured
removed_count = 0
if self.platform.remove_from_groups_on_unenroll:
from rbac.models import RBACMembership
removed = RBACMembership.objects.filter(rbac_group=rbac_group).exclude(user_id__in=processed_users)
removed_count = removed.count()
removed.delete()
logger.info(f"Removed {removed_count} unenrolled users from RBAC group {rbac_group.name}")
result = {'synced': synced_count, 'removed': removed_count, 'synced_at': timezone.now().isoformat()}
logger.info(f"NRPS sync complete for {rbac_group.name}: {result}")
return result
def _get_or_create_user_from_nrps(self, member):
"""
Get or create MediaCMS user from NRPS member data
Args:
member: Dict of member data from NRPS
Returns:
User instance or None
"""
user_id = member.get('user_id')
if not user_id:
logger.warning("NRPS member missing user_id")
return None
# Check for existing mapping
mapping = LTIUserMapping.objects.filter(platform=self.platform, lti_user_id=user_id).select_related('user').first()
if mapping:
# User already exists
return mapping.user
# Create new user from NRPS data
name = member.get('name', '')
email = member.get('email', '')
given_name = member.get('given_name', '')
family_name = member.get('family_name', '')
# Generate username
username = generate_username_from_lti(user_id, email, given_name, family_name)
# Check if username exists
if User.objects.filter(username=username).exists():
import hashlib
username = f"{username}_{hashlib.md5(user_id.encode()).hexdigest()[:6]}"
# Create user
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)
# Create mapping
LTIUserMapping.objects.create(platform=self.platform, lti_user_id=user_id, user=user, email=email, given_name=given_name, family_name=family_name, name=name)
# Mark email as verified
if email:
try:
from allauth.account.models import EmailAddress
EmailAddress.objects.create(user=user, email=email, verified=True, primary=True)
except Exception as e:
logger.warning(f"Could not create EmailAddress for NRPS user: {e}")
logger.info(f"Created user {username} from NRPS data")
return user

23
lti/urls.py Normal file
View File

@@ -0,0 +1,23 @@
"""
LTI 1.3 URL Configuration for MediaCMS
"""
from django.urls import path
from . import deep_linking, views
app_name = 'lti'
urlpatterns = [
# LTI 1.3 Launch Flow
path('oidc/login/', views.OIDCLoginView.as_view(), name='oidc_login'),
path('launch/', views.LaunchView.as_view(), name='launch'),
path('jwks/', views.JWKSView.as_view(), name='jwks'),
# Deep Linking
path('select-media/', deep_linking.SelectMediaView.as_view(), name='select_media'),
# LTI-authenticated pages
path('my-media/', views.MyMediaLTIView.as_view(), name='my_media'),
path('embed/<str:friendly_token>/', views.EmbedMediaLTIView.as_view(), name='embed_media'),
# Manual sync
path('sync/<int:platform_id>/<str:context_id>/', views.ManualSyncView.as_view(), name='manual_sync'),
]

379
lti/views.py Normal file
View File

@@ -0,0 +1,379 @@
"""
LTI 1.3 Views for MediaCMS
Implements the LTI 1.3 / LTI Advantage flow:
- OIDC Login Initiation
- LTI Launch (JWT validation and processing)
- JWKS endpoint (public keys)
- My Media view (iframe-compatible)
- Embed Media view (LTI-authenticated)
- Manual NRPS Sync
"""
import logging
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
from pylti1p3.exception import LtiException
from pylti1p3.message_launch import MessageLaunch
from pylti1p3.oidc_login import OIDCLogin
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from files.models import Media
from rbac.models import RBACMembership
from .adapters import DjangoSessionService, DjangoToolConfig
from .handlers import (
apply_lti_roles,
create_lti_session,
provision_lti_context,
provision_lti_user,
validate_lti_session,
)
from .models import LTILaunchLog, LTIPlatform, LTIResourceLink
from .services import LTINRPSClient
logger = logging.getLogger(__name__)
def get_client_ip(request):
"""Get client IP address from request"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
@method_decorator(csrf_exempt, name='dispatch')
class OIDCLoginView(View):
"""
OIDC Login Initiation - Step 1 of LTI 1.3 launch
Flow: Moodle → This endpoint → Redirect to Moodle auth endpoint
"""
def get(self, request):
return self.handle_oidc_login(request)
def post(self, request):
return self.handle_oidc_login(request)
def handle_oidc_login(self, request):
"""Handle OIDC login initiation"""
try:
# Get target_link_uri and other OIDC params
target_link_uri = request.GET.get('target_link_uri') or request.POST.get('target_link_uri')
iss = request.GET.get('iss') or request.POST.get('iss')
client_id = request.GET.get('client_id') or request.POST.get('client_id')
if not all([target_link_uri, iss, client_id]):
return JsonResponse({'error': 'Missing required OIDC parameters'}, status=400)
# Get platform configuration
platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=client_id, active=True)
# Create tool config for this platform
tool_config = DjangoToolConfig.from_platform(platform)
# Create OIDC login handler
launch_data_storage = DjangoSessionService(request)
oidc_login = OIDCLogin(request, tool_config, launch_data_storage=launch_data_storage)
# Redirect to platform's authorization endpoint
redirect_obj = oidc_login.enable_check_cookies().redirect(target_link_uri)
return HttpResponseRedirect(redirect_obj.get_redirect_url())
except LtiException as e:
logger.error(f"LTI OIDC Login Error: {str(e)}")
return render(request, 'lti/launch_error.html', {'error': 'OIDC Login Failed', 'message': str(e)}, status=400)
except Exception as e:
logger.error(f"OIDC Login Error: {str(e)}", exc_info=True)
return JsonResponse({'error': 'Internal server error during OIDC login'}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(xframe_options_exempt, name='dispatch')
class LaunchView(View):
"""
LTI Launch Handler - Step 3 of LTI 1.3 launch
Flow: Moodle → This endpoint (with JWT) → Validate → Provision → Session → Redirect
"""
def post(self, request):
"""Handle LTI launch with JWT validation"""
platform = None
user = None
error_message = ''
claims = {}
try:
# Get issuer from request
id_token = request.POST.get('id_token')
if not id_token:
raise ValueError("Missing id_token in launch request")
# Decode JWT to get issuer (without validation first)
import jwt
unverified = jwt.decode(id_token, options={"verify_signature": False})
iss = unverified.get('iss')
aud = unverified.get('aud')
# Get platform
platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=aud, active=True)
# Create tool config
tool_config = DjangoToolConfig.from_platform(platform)
# Validate JWT and get launch data
launch_data_storage = DjangoSessionService(request)
message_launch = MessageLaunch(request, tool_config, launch_data_storage=launch_data_storage)
# Get validated launch data
launch_data = message_launch.get_launch_data()
claims = self.sanitize_claims(launch_data)
# Extract key claims
sub = launch_data.get('sub')
resource_link = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {})
resource_link_id = resource_link.get('id', 'default')
roles = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/roles', [])
# Check launch type
message_type = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/message_type')
if message_type == 'LtiDeepLinkingRequest':
# Deep linking request - handle separately
return self.handle_deep_linking_launch(request, message_launch, platform, launch_data)
# Provision user
if platform.auto_create_users:
user = provision_lti_user(platform, launch_data)
else:
# Must find existing user
from .models import LTIUserMapping
mapping = LTIUserMapping.objects.filter(platform=platform, lti_user_id=sub).first()
if not mapping:
raise ValueError("User auto-creation disabled and no existing mapping found")
user = mapping.user
# Provision context (category + RBAC group)
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)
# Apply roles
apply_lti_roles(user, platform, roles, rbac_group)
else:
# No context - might be a direct media embed
resource_link_obj = None
# Create session
create_lti_session(request, user, message_launch, platform)
# Log successful launch
LTILaunchLog.objects.create(platform=platform, user=user, resource_link=resource_link_obj, launch_type='resource_link', success=True, claims=claims, ip_address=get_client_ip(request))
# Determine where to redirect
redirect_url = self.determine_redirect(launch_data, resource_link_obj)
return HttpResponseRedirect(redirect_url)
except LtiException as e:
error_message = f"LTI Launch Error: {str(e)}"
logger.error(error_message)
except Exception as e:
error_message = f"Launch Error: {str(e)}"
logger.error(error_message, exc_info=True)
# Log failed launch
if platform:
LTILaunchLog.objects.create(platform=platform, user=user, launch_type='resource_link', success=False, error_message=error_message, claims=claims, ip_address=get_client_ip(request))
return render(request, 'lti/launch_error.html', {'error': 'LTI Launch Failed', 'message': error_message}, status=400)
def sanitize_claims(self, claims):
"""Remove sensitive data from claims before logging"""
safe_claims = claims.copy()
# Remove any sensitive keys if needed
return safe_claims
def determine_redirect(self, launch_data, resource_link):
"""Determine where to redirect after successful launch"""
# Check for custom parameters indicating what to show
custom = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/custom', {})
# Check if specific media is requested
media_id = custom.get('media_id') or custom.get('media_friendly_token')
if media_id:
try:
media = Media.objects.get(friendly_token=media_id)
return reverse('lti:embed_media', args=[media.friendly_token])
except Media.DoesNotExist:
pass
# Check resource link for linked media
if resource_link and resource_link.media:
return reverse('lti:embed_media', args=[resource_link.media.friendly_token])
# Default: redirect to my media
return reverse('lti:my_media')
def handle_deep_linking_launch(self, request, message_launch, platform, launch_data):
"""Handle deep linking request"""
# Store deep link data in session
deep_link = message_launch.get_deep_link()
request.session['lti_deep_link'] = {
'deep_link_return_url': deep_link.get_response_url(),
'deployment_id': launch_data.get('https://purl.imsglobal.org/spec/lti/claim/deployment_id'),
'platform_id': platform.id,
}
# Redirect to media selection page
return HttpResponseRedirect(reverse('lti:select_media'))
class JWKSView(View):
"""
JWKS Endpoint - Provides tool's public keys
Used by Moodle to validate signatures from MediaCMS
"""
def get(self, request):
"""Return tool's public JWK Set"""
# For now, return empty JWKS since we're not signing responses
# In the future, we can generate and store keys for signing deep linking responses
jwks = {"keys": []}
return JsonResponse(jwks, content_type='application/json')
@method_decorator(xframe_options_exempt, name='dispatch')
class MyMediaLTIView(View):
"""
My Media page for LTI-authenticated users
Shows user's media profile in iframe
"""
def get(self, request):
"""Display my media page"""
# Validate LTI session
lti_session = validate_lti_session(request)
if not lti_session:
return JsonResponse({'error': 'Not authenticated via LTI'}, status=403)
# Redirect to user's profile page
# The existing user profile page is already iframe-compatible
return HttpResponseRedirect(f"/user/{request.user.username}")
@method_decorator(xframe_options_exempt, name='dispatch')
class EmbedMediaLTIView(View):
"""
Embed media with LTI authentication
Pattern: Extends existing /embed functionality
"""
def get(self, request, friendly_token):
"""Display embedded media"""
media = get_object_or_404(Media, friendly_token=friendly_token)
# Check LTI session
lti_session = validate_lti_session(request)
if lti_session and request.user.is_authenticated:
# Check RBAC access via course membership
if request.user.has_member_access_to_media(media):
can_view = True
else:
can_view = False
else:
# Fall back to public state check
can_view = media.state == 'public'
if not can_view:
return JsonResponse({'error': 'Access denied', 'message': 'You do not have permission to view this media'}, status=403)
# Redirect to existing embed page
# The existing embed page already handles media display in iframes
return HttpResponseRedirect(f"/embed?m={friendly_token}")
class ManualSyncView(APIView):
"""
Manual NRPS sync for course members/roles
Endpoint: POST /lti/sync/<platform_id>/<context_id>/
Requires: User must be manager in the course RBAC group
"""
permission_classes = [IsAuthenticated]
def post(self, request, platform_id, context_id):
"""Manually trigger NRPS sync"""
try:
# Get platform
platform = get_object_or_404(LTIPlatform, id=platform_id, active=True)
# Find resource link by context
resource_link = LTIResourceLink.objects.filter(platform=platform, context_id=context_id).first()
if not resource_link:
return Response({'error': 'Context not found', 'message': f'No resource link found for context {context_id}'}, status=status.HTTP_404_NOT_FOUND)
# Verify user has manager role in the course
rbac_group = resource_link.rbac_group
if not rbac_group:
return Response({'error': 'No RBAC group', 'message': 'This context does not have an associated RBAC group'}, status=status.HTTP_400_BAD_REQUEST)
is_manager = RBACMembership.objects.filter(user=request.user, rbac_group=rbac_group, role='manager').exists()
if not is_manager:
return Response({'error': 'Insufficient permissions', 'message': 'You must be a course manager to sync members'}, status=status.HTTP_403_FORBIDDEN)
# Check NRPS is enabled
if not platform.enable_nrps:
return Response({'error': 'NRPS disabled', 'message': 'Names and Role Provisioning Service is disabled for this platform'}, status=status.HTTP_400_BAD_REQUEST)
# Get last successful launch for NRPS endpoint
last_launch = LTILaunchLog.objects.filter(platform=platform, resource_link=resource_link, success=True).order_by('-created_at').first()
if not last_launch:
return Response({'error': 'No launch data', 'message': 'No successful launch data found for NRPS'}, status=status.HTTP_400_BAD_REQUEST)
# Perform NRPS sync
nrps_client = LTINRPSClient(platform, last_launch.claims)
result = nrps_client.sync_members_to_rbac_group(rbac_group)
return Response(
{
'status': 'success',
'message': f'Successfully synced {result["synced"]} members',
'synced_count': result['synced'],
'removed_count': result.get('removed', 0),
'synced_at': result['synced_at'],
},
status=status.HTTP_200_OK,
)
except Exception as e:
logger.error(f"NRPS sync error: {str(e)}", exc_info=True)
return Response({'error': 'Sync failed', 'message': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Returning to Course...</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background-color: #f5f5f5;
}
.loading {
text-align: center;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #2196F3;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="loading">
<div class="spinner"></div>
<p>Returning to your course...</p>
</div>
<!-- Auto-submit form that posts JWT back to Moodle -->
<form id="deepLinkReturnForm" method="post" action="{{ return_url }}" style="display: none;">
<input type="hidden" name="JWT" value="{{ jwt }}">
</form>
<script>
// Auto-submit on page load
document.getElementById('deepLinkReturnForm').submit();
</script>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LTI Launch Error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}
.error-container {
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #d32f2f;
margin-top: 0;
}
.error-icon {
font-size: 48px;
color: #d32f2f;
margin-bottom: 20px;
}
.error-message {
background: #ffebee;
border-left: 4px solid #d32f2f;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.help-text {
color: #666;
font-size: 14px;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">⚠️</div>
<h1>{{ error }}</h1>
<div class="error-message">
<strong>Error Details:</strong><br>
{{ message }}
</div>
<div class="help-text">
<p>If this problem persists, please contact your system administrator.</p>
<p>You may close this window and try again.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,222 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Select Media - MediaCMS</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.header {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
margin: 0 0 10px 0;
}
.filter-bar {
background: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 80px;
}
.media-card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s;
cursor: pointer;
}
.media-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.media-card.selected {
border: 3px solid #2196F3;
}
.media-thumbnail {
width: 100%;
height: 180px;
object-fit: cover;
background: #eee;
}
.media-info {
padding: 15px;
}
.media-title {
font-weight: 600;
margin: 0 0 5px 0;
font-size: 14px;
}
.media-meta {
font-size: 12px;
color: #666;
}
.checkbox-wrapper {
position: absolute;
top: 10px;
right: 10px;
}
.checkbox-wrapper input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 20px;
box-shadow: 0 -2px 4px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.btn-primary {
background: #2196F3;
color: white;
}
.btn-primary:hover {
background: #1976D2;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.selected-count {
color: #666;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
</style>
</head>
<body>
<div class="header">
<h1>Select Media to Embed</h1>
<p>Choose one or more media items to add to your course</p>
</div>
<div class="filter-bar">
<label>
<input type="checkbox" id="myMediaOnly" {% if show_my_media_only %}checked{% endif %}>
Show only my media
</label>
</div>
<form id="selectMediaForm" method="post">
{% csrf_token %}
<div class="media-grid">
{% for media in media_list %}
<div class="media-card" data-media-id="{{ media.id }}" onclick="toggleMedia({{ media.id }})">
<div style="position: relative;">
{% if media.thumbnail_url %}
<img src="{{ media.thumbnail_url }}" alt="{{ media.title }}" class="media-thumbnail">
{% else %}
<div class="media-thumbnail" style="display: flex; align-items: center; justify-content: center; background: #e0e0e0;">
<span style="font-size: 48px;">🎬</span>
</div>
{% endif %}
<div class="checkbox-wrapper">
<input type="checkbox" name="media_ids[]" value="{{ media.id }}" id="media_{{ media.id }}">
</div>
</div>
<div class="media-info">
<h3 class="media-title">{{ media.title }}</h3>
<div class="media-meta">
By {{ media.user.name|default:media.user.username }}<br>
{{ media.add_date|date:"M d, Y" }}
</div>
</div>
</div>
{% empty %}
<div class="empty-state">
<h3>No media found</h3>
<p>Try adjusting your filter settings</p>
</div>
{% endfor %}
</div>
<div class="bottom-bar">
<span class="selected-count">
<strong id="selectedCount">0</strong> media selected
</span>
<div>
<button type="button" class="btn btn-secondary" onclick="window.close()">Cancel</button>
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>Add to Course</button>
</div>
</div>
</form>
<script>
function toggleMedia(mediaId) {
const checkbox = document.getElementById('media_' + mediaId);
checkbox.checked = !checkbox.checked;
updateSelection();
}
function updateSelection() {
const checkboxes = document.querySelectorAll('input[name="media_ids[]"]');
let count = 0;
checkboxes.forEach(cb => {
const card = cb.closest('.media-card');
if (cb.checked) {
card.classList.add('selected');
count++;
} else {
card.classList.remove('selected');
}
});
document.getElementById('selectedCount').textContent = count;
document.getElementById('submitBtn').disabled = count === 0;
}
// Initialize
document.querySelectorAll('input[name="media_ids[]"]').forEach(cb => {
cb.addEventListener('change', function(e) {
e.stopPropagation();
updateSelection();
});
});
// My media filter
document.getElementById('myMediaOnly').addEventListener('change', function() {
const url = new URL(window.location);
url.searchParams.set('my_media_only', this.checked);
window.location = url.toString();
});
updateSelection();
</script>
</body>
</html>