From 295578dae2a5043f1478201920fc189cd47dbabf Mon Sep 17 00:00:00 2001 From: Markos Gogoulos Date: Wed, 24 Dec 2025 17:28:12 +0200 Subject: [PATCH] lti --- cms/dev_settings.py | 1 + cms/settings.py | 15 + cms/urls.py | 1 + files/admin.py | 4 +- ...course_category_lti_context_id_and_more.py | 31 ++ files/models/category.py | 7 + lti/__init__.py | 6 + lti/adapters.py | 203 +++++++++ lti/admin.py | 157 +++++++ lti/apps.py | 12 + lti/deep_linking.py | 165 ++++++++ lti/handlers.py | 388 ++++++++++++++++++ lti/migrations/0001_initial.py | 200 +++++++++ lti/migrations/__init__.py | 0 lti/models.py | 185 +++++++++ lti/serializers.py | 42 ++ lti/services.py | 196 +++++++++ lti/urls.py | 23 ++ lti/views.py | 379 +++++++++++++++++ templates/lti/deep_link_return.html | 51 +++ templates/lti/launch_error.html | 58 +++ templates/lti/select_media.html | 222 ++++++++++ 22 files changed, 2345 insertions(+), 1 deletion(-) create mode 100755 files/migrations/0015_category_is_lms_course_category_lti_context_id_and_more.py create mode 100644 lti/__init__.py create mode 100644 lti/adapters.py create mode 100644 lti/admin.py create mode 100644 lti/apps.py create mode 100644 lti/deep_linking.py create mode 100644 lti/handlers.py create mode 100644 lti/migrations/0001_initial.py create mode 100644 lti/migrations/__init__.py create mode 100755 lti/models.py create mode 100644 lti/serializers.py create mode 100644 lti/services.py create mode 100644 lti/urls.py create mode 100644 lti/views.py create mode 100644 templates/lti/deep_link_return.html create mode 100644 templates/lti/launch_error.html create mode 100644 templates/lti/select_media.html diff --git a/cms/dev_settings.py b/cms/dev_settings.py index c3a45a19..b83dd53c 100644 --- a/cms/dev_settings.py +++ b/cms/dev_settings.py @@ -24,6 +24,7 @@ INSTALLED_APPS = [ "actions.apps.ActionsConfig", "rbac.apps.RbacConfig", "identity_providers.apps.IdentityProvidersConfig", + "lti.apps.LtiConfig", "debug_toolbar", "mptt", "crispy_forms", diff --git a/cms/settings.py b/cms/settings.py index 34afe548..686092a6 100644 --- a/cms/settings.py +++ b/cms/settings.py @@ -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 diff --git a/cms/urls.py b/cms/urls.py index 6444f677..e6dc0850 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -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\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), diff --git a/files/admin.py b/files/admin.py index c100e278..0a44533d 100644 --- a/files/admin.py +++ b/files/admin.py @@ -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() diff --git a/files/migrations/0015_category_is_lms_course_category_lti_context_id_and_more.py b/files/migrations/0015_category_is_lms_course_category_lti_context_id_and_more.py new file mode 100755 index 00000000..93a1974a --- /dev/null +++ b/files/migrations/0015_category_is_lms_course_category_lti_context_id_and_more.py @@ -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' + ), + ), + ] diff --git a/files/models/category.py b/files/models/category.py index 31c9f7a1..5f397c3c 100644 --- a/files/models/category.py +++ b/files/models/category.py @@ -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 diff --git a/lti/__init__.py b/lti/__init__.py new file mode 100644 index 00000000..2f37542f --- /dev/null +++ b/lti/__init__.py @@ -0,0 +1,6 @@ +""" +LTI 1.3 Integration for MediaCMS +Enables integration with Learning Management Systems like Moodle +""" + +default_app_config = 'lti.apps.LtiConfig' diff --git a/lti/adapters.py b/lti/adapters.py new file mode 100644 index 00000000..1805e19b --- /dev/null +++ b/lti/adapters.py @@ -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) diff --git a/lti/admin.py b/lti/admin.py new file mode 100644 index 00000000..e50392dc --- /dev/null +++ b/lti/admin.py @@ -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('✓ Active') + return format_html('✗ Inactive') + + 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('{}', 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('{}', 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('{}', 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('✓ Success') + return format_html('✗ Failed') + + success_badge.short_description = 'Status' + + def user_link(self, obj): + if obj.user: + return format_html('{}', 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 diff --git a/lti/apps.py b/lti/apps.py new file mode 100644 index 00000000..c4b4a1ff --- /dev/null +++ b/lti/apps.py @@ -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 diff --git a/lti/deep_linking.py b/lti/deep_linking.py new file mode 100644 index 00000000..f34f656f --- /dev/null +++ b/lti/deep_linking.py @@ -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" diff --git a/lti/handlers.py b/lti/handlers.py new file mode 100644 index 00000000..fa1bef01 --- /dev/null +++ b/lti/handlers.py @@ -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 diff --git a/lti/migrations/0001_initial.py b/lti/migrations/0001_initial.py new file mode 100644 index 00000000..5414ae95 --- /dev/null +++ b/lti/migrations/0001_initial.py @@ -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')}, + ), + ] diff --git a/lti/migrations/__init__.py b/lti/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lti/models.py b/lti/models.py new file mode 100755 index 00000000..a882e249 --- /dev/null +++ b/lti/models.py @@ -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')})" diff --git a/lti/serializers.py b/lti/serializers.py new file mode 100644 index 00000000..6b684297 --- /dev/null +++ b/lti/serializers.py @@ -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'] diff --git a/lti/services.py b/lti/services.py new file mode 100644 index 00000000..5d2aafc9 --- /dev/null +++ b/lti/services.py @@ -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 diff --git a/lti/urls.py b/lti/urls.py new file mode 100644 index 00000000..05837c1e --- /dev/null +++ b/lti/urls.py @@ -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//', views.EmbedMediaLTIView.as_view(), name='embed_media'), + # Manual sync + path('sync///', views.ManualSyncView.as_view(), name='manual_sync'), +] diff --git a/lti/views.py b/lti/views.py new file mode 100644 index 00000000..11f6564e --- /dev/null +++ b/lti/views.py @@ -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/// + 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) diff --git a/templates/lti/deep_link_return.html b/templates/lti/deep_link_return.html new file mode 100644 index 00000000..7ebe908a --- /dev/null +++ b/templates/lti/deep_link_return.html @@ -0,0 +1,51 @@ + + + + + + Returning to Course... + + + +
+
+

Returning to your course...

+
+ + + + + + + diff --git a/templates/lti/launch_error.html b/templates/lti/launch_error.html new file mode 100644 index 00000000..f9c689b5 --- /dev/null +++ b/templates/lti/launch_error.html @@ -0,0 +1,58 @@ + + + + + + LTI Launch Error + + + +
+
⚠️
+

{{ error }}

+
+ Error Details:
+ {{ message }} +
+
+

If this problem persists, please contact your system administrator.

+

You may close this window and try again.

+
+
+ + diff --git a/templates/lti/select_media.html b/templates/lti/select_media.html new file mode 100644 index 00000000..6c40c1d9 --- /dev/null +++ b/templates/lti/select_media.html @@ -0,0 +1,222 @@ +{% load static %} + + + + + + Select Media - MediaCMS + + + +
+

Select Media to Embed

+

Choose one or more media items to add to your course

+
+ +
+ +
+ +
+ {% csrf_token %} +
+ {% for media in media_list %} +
+
+ {% if media.thumbnail_url %} + {{ media.title }} + {% else %} +
+ 🎬 +
+ {% endif %} +
+ +
+
+
+

{{ media.title }}

+
+ By {{ media.user.name|default:media.user.username }}
+ {{ media.add_date|date:"M d, Y" }} +
+
+
+ {% empty %} +
+

No media found

+

Try adjusting your filter settings

+
+ {% endfor %} +
+ +
+ + 0 media selected + +
+ + +
+
+
+ + + +