mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-01-23 00:23:00 -05:00
lti
This commit is contained in:
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
|
|||||||
"actions.apps.ActionsConfig",
|
"actions.apps.ActionsConfig",
|
||||||
"rbac.apps.RbacConfig",
|
"rbac.apps.RbacConfig",
|
||||||
"identity_providers.apps.IdentityProvidersConfig",
|
"identity_providers.apps.IdentityProvidersConfig",
|
||||||
|
"lti.apps.LtiConfig",
|
||||||
"debug_toolbar",
|
"debug_toolbar",
|
||||||
"mptt",
|
"mptt",
|
||||||
"crispy_forms",
|
"crispy_forms",
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ INSTALLED_APPS = [
|
|||||||
"actions.apps.ActionsConfig",
|
"actions.apps.ActionsConfig",
|
||||||
"rbac.apps.RbacConfig",
|
"rbac.apps.RbacConfig",
|
||||||
"identity_providers.apps.IdentityProvidersConfig",
|
"identity_providers.apps.IdentityProvidersConfig",
|
||||||
|
"lti.apps.LtiConfig",
|
||||||
"debug_toolbar",
|
"debug_toolbar",
|
||||||
"mptt",
|
"mptt",
|
||||||
"crispy_forms",
|
"crispy_forms",
|
||||||
@@ -555,6 +556,7 @@ DJANGO_ADMIN_URL = "admin/"
|
|||||||
USE_SAML = False
|
USE_SAML = False
|
||||||
USE_RBAC = False
|
USE_RBAC = False
|
||||||
USE_IDENTITY_PROVIDERS = False
|
USE_IDENTITY_PROVIDERS = False
|
||||||
|
USE_LTI = False # Enable LTI 1.3 integration
|
||||||
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
||||||
|
|
||||||
USE_ROUNDED_CORNERS = True
|
USE_ROUNDED_CORNERS = True
|
||||||
@@ -650,3 +652,16 @@ if USERS_NEEDS_TO_BE_APPROVED:
|
|||||||
)
|
)
|
||||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||||
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
|
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("files.urls")),
|
||||||
re_path(r"^", include("users.urls")),
|
re_path(r"^", include("users.urls")),
|
||||||
re_path(r"^accounts/", include("allauth.urls")),
|
re_path(r"^accounts/", include("allauth.urls")),
|
||||||
|
re_path(r"^lti/", include("lti.urls")),
|
||||||
re_path(r"^api-auth/", include("rest_framework.urls")),
|
re_path(r"^api-auth/", include("rest_framework.urls")),
|
||||||
path(settings.DJANGO_ADMIN_URL, admin.site.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'),
|
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:
|
class Meta:
|
||||||
model = Category
|
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):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
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',
|
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):
|
def __str__(self):
|
||||||
return self.title
|
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