feat: LTI support and Moodle plugin

This commit is contained in:
Markos Gogoulos
2026-05-11 12:47:09 +03:00
committed by GitHub
parent b7427869b6
commit 55ab7ff34f
307 changed files with 19966 additions and 3748 deletions
+72 -19
View File
@@ -1,5 +1,6 @@
import json
import os
import re
from django.conf import settings
from django.contrib import messages
@@ -7,6 +8,7 @@ from django.contrib.auth.decorators import login_required
from django.core.mail import EmailMessage
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import render
from django.utils.html import mark_safe, strip_tags
from django.views.decorators.csrf import csrf_exempt
from cms.version import VERSION
@@ -24,7 +26,7 @@ from ..forms import (
WhisperSubtitlesForm,
)
from ..frontend_translations import translate_string
from ..helpers import get_alphanumeric_only
from ..helpers import get_alphanumeric_and_spaces
from ..methods import (
can_transcribe_video,
create_video_trim_request,
@@ -243,6 +245,31 @@ def history(request):
return render(request, "cms/history.html", context)
_TIMESTAMP_RE = re.compile(r'^(?:(\d+):)?([0-5]?\d):([0-5]?\d)(?:\.(\d{1,3}))?$')
def _timestamp_to_seconds(value):
"""Parse 'HH:MM:SS.mmm', 'MM:SS.mmm', etc., or a numeric value, into float seconds.
Returns None if the value can't be parsed.
"""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if not isinstance(value, str):
return None
match = _TIMESTAMP_RE.match(value.strip())
if not match:
return None
hours = int(match.group(1)) if match.group(1) else 0
minutes = int(match.group(2))
seconds = int(match.group(3))
millis_str = match.group(4) or '0'
millis = int(millis_str.ljust(3, '0'))
return hours * 3600 + minutes * 60 + seconds + millis / 1000.0
@csrf_exempt
@login_required
def video_chapters(request, friendly_token):
@@ -262,20 +289,39 @@ def video_chapters(request, friendly_token):
data = request_data.get("chapters")
if data is None:
return JsonResponse({'success': False, 'error': 'Request must contain "chapters" array'}, status=400)
if not isinstance(data, list):
return JsonResponse({'success': False, 'error': '"chapters" must be an array'}, status=400)
if len(data) > 200:
return JsonResponse({'success': False, 'error': 'Too many chapters (max 200)'}, status=400)
chapters = []
for _, chapter_data in enumerate(data):
start_time = chapter_data.get('startTime')
end_time = chapter_data.get('endTime')
for chapter_data in data:
if not isinstance(chapter_data, dict):
continue
raw_start = chapter_data.get('startTime')
raw_end = chapter_data.get('endTime')
chapter_title = chapter_data.get('chapterTitle')
if start_time and end_time and chapter_title:
chapters.append(
{
'startTime': start_time,
'endTime': end_time,
'chapterTitle': chapter_title,
}
)
start_seconds = _timestamp_to_seconds(raw_start)
end_seconds = _timestamp_to_seconds(raw_end)
if start_seconds is None or end_seconds is None:
continue
if start_seconds < 0 or end_seconds < 0 or start_seconds >= end_seconds:
continue
if not isinstance(chapter_title, str) or not chapter_title.strip():
continue
chapter_title = strip_tags(chapter_title).strip()[:500]
if not chapter_title:
continue
chapters.append(
{
'startTime': raw_start if isinstance(raw_start, str) else start_seconds,
'endTime': raw_end if isinstance(raw_end, str) else end_seconds,
'chapterTitle': chapter_title,
}
)
except Exception as e: # noqa
return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with startTime, endTime, chapterTitle'}, status=400)
@@ -310,8 +356,8 @@ def edit_media(request):
media.tags.remove(tag)
if form.cleaned_data.get("new_tags"):
for tag in form.cleaned_data.get("new_tags").split(","):
tag = get_alphanumeric_only(tag)
tag = tag[:99]
tag = get_alphanumeric_and_spaces(tag)
tag = tag[:100]
if tag:
try:
tag = Tag.objects.get(title=tag)
@@ -350,13 +396,13 @@ def publish_media(request):
return HttpResponseRedirect(media.get_absolute_url())
if request.method == "POST":
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media)
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media, request=request)
if form.is_valid():
media = form.save()
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = MediaPublishForm(request.user, instance=media)
form = MediaPublishForm(request.user, instance=media, request=request)
return render(
request,
@@ -449,11 +495,18 @@ def edit_chapters(request):
if not (is_mediacms_editor(request.user) or request.user.has_contributor_access_to_media(media)):
return HttpResponseRedirect("/")
chapters = media.chapter_data
_html_escapes = str.maketrans({'<': r'\u003C', '>': r'\u003E', '&': r'\u0026'})
chapters_json = mark_safe(json.dumps(media.chapter_data).translate(_html_escapes))
return render(
request,
"cms/edit_chapters.html",
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token, "chapters": chapters},
{
"media_object": media,
"add_subtitle_url": media.add_subtitle_url,
"media_file_path": helpers.url_from_path(media.media_file.path),
"media_id": media.friendly_token,
"chapters": chapters_json,
},
)
@@ -547,7 +600,7 @@ def embed_media(request):
media = Media.objects.values("title").filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
return HttpResponse('This media no longer exists', status=404, content_type='text/plain; charset=utf-8')
context = {}
context["media"] = friendly_token