mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-01-20 07:12:58 -05:00
lti
This commit is contained in:
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"lti.apps.LtiConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
6
lti/__init__.py
Normal 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
203
lti/adapters.py
Normal 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
157
lti/admin.py
Normal 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
12
lti/apps.py
Normal 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
165
lti/deep_linking.py
Normal 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
388
lti/handlers.py
Normal 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
|
||||
200
lti/migrations/0001_initial.py
Normal file
200
lti/migrations/0001_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
0
lti/migrations/__init__.py
Normal file
0
lti/migrations/__init__.py
Normal file
185
lti/models.py
Executable file
185
lti/models.py
Executable 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
42
lti/serializers.py
Normal 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
196
lti/services.py
Normal 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
23
lti/urls.py
Normal 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
379
lti/views.py
Normal 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)
|
||||
51
templates/lti/deep_link_return.html
Normal file
51
templates/lti/deep_link_return.html
Normal 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>
|
||||
58
templates/lti/launch_error.html
Normal file
58
templates/lti/launch_error.html
Normal 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>
|
||||
222
templates/lti/select_media.html
Normal file
222
templates/lti/select_media.html
Normal 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>
|
||||
Reference in New Issue
Block a user