mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-06-07 01:14:19 -04:00
feat: LTI support and Moodle plugin
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Import all views for backward compatibility
|
||||
|
||||
from .auth import custom_login_view, saml_metadata # noqa: F401
|
||||
from .categories import CategoryList, TagList # noqa: F401
|
||||
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
|
||||
from .comments import CommentDetail, CommentList # noqa: F401
|
||||
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
|
||||
from .media import MediaActions # noqa: F401
|
||||
@@ -9,6 +9,7 @@ from .media import MediaBulkUserActions # noqa: F401
|
||||
from .media import MediaDetail # noqa: F401
|
||||
from .media import MediaList # noqa: F401
|
||||
from .media import MediaSearch # noqa: F401
|
||||
from .media import media_share # noqa: F401
|
||||
from .pages import about # noqa: F401
|
||||
from .pages import add_subtitle # noqa: F401
|
||||
from .pages import approval_required # noqa: F401
|
||||
|
||||
@@ -23,17 +23,18 @@ class CategoryList(APIView):
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
base_filters = {}
|
||||
|
||||
if not is_mediacms_editor(request.user):
|
||||
base_filters = {"is_rbac_category": False}
|
||||
|
||||
base_queryset = Category.objects.prefetch_related("user")
|
||||
categories = base_queryset.filter(**base_filters)
|
||||
show_lms = getattr(settings, 'SHOW_LMS_COURSES_IN_CATEGORIES', True)
|
||||
categories = Category.objects.prefetch_related("user")
|
||||
|
||||
if not show_lms:
|
||||
categories = categories.filter(is_lms_course=False)
|
||||
|
||||
if not is_mediacms_editor(request.user):
|
||||
categories = categories.filter(is_rbac_category=False)
|
||||
if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
|
||||
rbac_categories = request.user.get_rbac_categories_as_member()
|
||||
if not show_lms:
|
||||
rbac_categories = rbac_categories.filter(is_lms_course=False)
|
||||
categories = categories.union(rbac_categories)
|
||||
|
||||
categories = categories.order_by("title")
|
||||
@@ -43,6 +44,27 @@ class CategoryList(APIView):
|
||||
return Response(ret)
|
||||
|
||||
|
||||
class CategoryListContributor(APIView):
|
||||
"""List LMS courses where the user has contributor access"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=['Categories'],
|
||||
operation_summary='Lists LMS courses for Contributors',
|
||||
operation_description='Lists LMS courses where the user has contributor access',
|
||||
responses={
|
||||
200: openapi.Response('response description', CategorySerializer),
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
if not request.user.is_authenticated:
|
||||
return Response([])
|
||||
|
||||
categories = request.user.get_rbac_categories_as_contributor().filter(is_lms_course=True)
|
||||
|
||||
serializer = CategorySerializer(categories.order_by("title"), many=True, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TagList(APIView):
|
||||
"""List tags"""
|
||||
|
||||
|
||||
@@ -75,8 +75,11 @@ class CommentDetail(APIView):
|
||||
try:
|
||||
media = Media.objects.select_related("user").get(friendly_token=friendly_token)
|
||||
self.check_object_permissions(self.request, media)
|
||||
if media.state == "private" and self.request.user != media.user:
|
||||
return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if media.state == "private":
|
||||
user = self.request.user
|
||||
has_access = user.is_authenticated and (user.has_member_access_to_media(media) or is_mediacms_editor(user))
|
||||
if not has_access:
|
||||
return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return media
|
||||
except PermissionDenied:
|
||||
return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -97,7 +100,7 @@ class CommentDetail(APIView):
|
||||
media = self.get_object(friendly_token)
|
||||
if isinstance(media, Response):
|
||||
return media
|
||||
comments = media.comments.filter().prefetch_related("user")
|
||||
comments = media.comments.filter().prefetch_related("user").order_by("-add_date")
|
||||
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
|
||||
paginator = pagination_class()
|
||||
page = paginator.paginate_queryset(comments, request)
|
||||
|
||||
+209
-21
@@ -2,8 +2,11 @@ from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import SearchQuery
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count, F, Prefetch, Q, prefetch_related_objects
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import permissions, status
|
||||
@@ -35,6 +38,8 @@ from ..methods import (
|
||||
)
|
||||
from ..models import (
|
||||
Category,
|
||||
Comment,
|
||||
EmbedMediaCourse,
|
||||
EncodeProfile,
|
||||
Media,
|
||||
MediaPermission,
|
||||
@@ -112,6 +117,8 @@ class MediaList(APIView):
|
||||
upload_date = params.get('upload_date', '').strip()
|
||||
duration = params.get('duration', '').strip()
|
||||
publish_state = params.get('publish_state', '').strip()
|
||||
shared_user = params.get('shared_user', '').strip()
|
||||
shared_group = params.get('shared_group', '').strip()
|
||||
query = params.get("q", "").strip().lower()
|
||||
|
||||
parsed_combined = False
|
||||
@@ -152,6 +159,7 @@ class MediaList(APIView):
|
||||
gte = datetime(year, 1, 1)
|
||||
|
||||
already_sorted = False
|
||||
include_sharing_info = False
|
||||
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
|
||||
|
||||
if show_param == "recommended":
|
||||
@@ -164,26 +172,41 @@ class MediaList(APIView):
|
||||
if not self.request.user.is_authenticated:
|
||||
media = Media.objects.none()
|
||||
else:
|
||||
media = Media.objects.filter(permissions__owner_user=self.request.user).prefetch_related("user", "tags").distinct()
|
||||
base_queryset = Media.objects.prefetch_related("user", "tags")
|
||||
conditions = Q(permissions__owner_user=self.request.user)
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_categories = request.user.get_rbac_categories_as_contributor()
|
||||
conditions |= Q(category__in=rbac_categories, user=self.request.user)
|
||||
|
||||
media = base_queryset.filter(conditions).distinct()
|
||||
include_sharing_info = True
|
||||
elif show_param == "shared_with_me":
|
||||
if not self.request.user.is_authenticated:
|
||||
media = Media.objects.none()
|
||||
else:
|
||||
base_queryset = Media.objects.prefetch_related("user", "tags")
|
||||
|
||||
# Build OR conditions similar to _get_media_queryset
|
||||
conditions = Q(permissions__user=request.user)
|
||||
exclude_lti_embed = request.GET.get('exclude_lti_embed') == '1'
|
||||
# in LTI, if this is set, show only media that are shared explicitly with user
|
||||
if exclude_lti_embed:
|
||||
conditions = Q(permissions__user=request.user, permissions__source=MediaPermission.SOURCE_EXPLICIT)
|
||||
else:
|
||||
conditions = Q(permissions__user=request.user)
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_categories = request.user.get_rbac_categories_as_member()
|
||||
conditions |= Q(category__in=rbac_categories)
|
||||
|
||||
media = base_queryset.filter(conditions).distinct()
|
||||
media = base_queryset.filter(conditions).exclude(user=request.user).distinct()
|
||||
include_sharing_info = True
|
||||
elif author_param:
|
||||
user_queryset = User.objects.all()
|
||||
user = get_object_or_404(user_queryset, username=author_param)
|
||||
if self.request.user == user or is_mediacms_editor(self.request.user):
|
||||
media = Media.objects.filter(user=user).prefetch_related("user", "tags")
|
||||
if self.request.user == user:
|
||||
include_sharing_info = True
|
||||
else:
|
||||
media = self._get_media_queryset(request, user)
|
||||
already_sorted = True
|
||||
@@ -234,6 +257,12 @@ class MediaList(APIView):
|
||||
elif publish_state in ['private', 'public', 'unlisted']:
|
||||
media = media.filter(state=publish_state)
|
||||
|
||||
if shared_user and include_sharing_info:
|
||||
media = media.filter(permissions__user__username=shared_user).distinct()
|
||||
|
||||
if shared_group and include_sharing_info:
|
||||
media = media.filter(category__is_rbac_category=True, category__rbac_groups__name=shared_group).distinct()
|
||||
|
||||
if not already_sorted:
|
||||
media = media.order_by(f"{ordering}{sort_by}")
|
||||
|
||||
@@ -243,6 +272,16 @@ class MediaList(APIView):
|
||||
|
||||
page = paginator.paginate_queryset(media, request)
|
||||
|
||||
prefetch_related_objects(page, 'tags')
|
||||
|
||||
if include_sharing_info:
|
||||
# this is the data for the Shared with me/by me pages on 'my media'
|
||||
prefetch_related_objects(
|
||||
page,
|
||||
Prefetch('permissions', queryset=MediaPermission.objects.select_related('user').exclude(user=request.user)),
|
||||
Prefetch('category', queryset=Category.objects.filter(is_rbac_category=True).prefetch_related('rbac_groups'), to_attr='rbac_categories_prefetched'),
|
||||
)
|
||||
|
||||
serializer = MediaSerializer(page, many=True, context={"request": request})
|
||||
|
||||
tags_set = set()
|
||||
@@ -253,6 +292,19 @@ class MediaList(APIView):
|
||||
|
||||
response = paginator.get_paginated_response(serializer.data)
|
||||
response.data['tags'] = tags
|
||||
|
||||
if include_sharing_info:
|
||||
shared_users = {}
|
||||
shared_groups = {}
|
||||
for media_obj in page:
|
||||
for perm in media_obj.permissions.all():
|
||||
shared_users[perm.user.username] = {"username": perm.user.username, "name": perm.user.name or perm.user.username}
|
||||
for cat in getattr(media_obj, 'rbac_categories_prefetched', []):
|
||||
for group in cat.rbac_groups.all():
|
||||
shared_groups[group.name] = {"name": group.name}
|
||||
response.data['shared_users'] = list(shared_users.values())
|
||||
response.data['shared_groups'] = list(shared_groups.values())
|
||||
|
||||
return response
|
||||
|
||||
@swagger_auto_schema(
|
||||
@@ -295,6 +347,7 @@ class MediaBulkUserActions(APIView):
|
||||
enum=[
|
||||
"enable_comments",
|
||||
"disable_comments",
|
||||
"delete_comments",
|
||||
"delete_media",
|
||||
"enable_download",
|
||||
"disable_download",
|
||||
@@ -313,6 +366,7 @@ class MediaBulkUserActions(APIView):
|
||||
"remove_from_category",
|
||||
"add_tags",
|
||||
"remove_tags",
|
||||
"course_cleanup",
|
||||
],
|
||||
),
|
||||
'playlist_ids': openapi.Schema(
|
||||
@@ -360,12 +414,15 @@ class MediaBulkUserActions(APIView):
|
||||
media_ids = request.data.get('media_ids', [])
|
||||
action = request.data.get('action')
|
||||
|
||||
if not media_ids:
|
||||
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not action:
|
||||
return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if action == "course_cleanup":
|
||||
return self._handle_course_cleanup(request, media_ids)
|
||||
|
||||
if not media_ids:
|
||||
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
|
||||
|
||||
if not media:
|
||||
@@ -379,6 +436,10 @@ class MediaBulkUserActions(APIView):
|
||||
media.update(enable_comments=False)
|
||||
return Response({"detail": f"Comments disabled for {media.count()} media items"})
|
||||
|
||||
elif action == "delete_comments":
|
||||
deleted_count, _ = Comment.objects.filter(media__in=media).delete()
|
||||
return Response({"detail": f"{deleted_count} comments deleted"})
|
||||
|
||||
elif action == "delete_media":
|
||||
count = media.count()
|
||||
media.delete()
|
||||
@@ -454,7 +515,16 @@ class MediaBulkUserActions(APIView):
|
||||
|
||||
m.save(update_fields=["state", "listable"])
|
||||
|
||||
return Response({"detail": f"State updated to {state} for {media.count()} media items"})
|
||||
remove_sharing = request.data.get('remove_sharing', False)
|
||||
|
||||
if remove_sharing:
|
||||
MediaPermission.objects.filter(media__in=media).delete()
|
||||
for m in media:
|
||||
rbac_cats = m.category.filter(is_rbac_category=True)
|
||||
if rbac_cats.exists():
|
||||
m.category.remove(*rbac_cats)
|
||||
|
||||
return Response({"detail": f"State updated to {state}"})
|
||||
|
||||
elif action == "change_owner":
|
||||
owner = request.data.get('owner')
|
||||
@@ -492,8 +562,9 @@ class MediaBulkUserActions(APIView):
|
||||
|
||||
users = (
|
||||
MediaPermission.objects.filter(media__in=media, permission=ownership_type)
|
||||
.exclude(user=request.user)
|
||||
.values('user__name', 'user__username')
|
||||
.annotate(media_count=Count('media', distinct=True))
|
||||
.annotate(media_count=Count('media'))
|
||||
.filter(media_count=media_count)
|
||||
)
|
||||
|
||||
@@ -561,7 +632,13 @@ class MediaBulkUserActions(APIView):
|
||||
elif action == "category_membership":
|
||||
media_count = media.count()
|
||||
|
||||
results = list(Category.objects.filter(media__in=media).values('title', 'uid').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
|
||||
# Categories where ALL selected media are members via the M2M relation
|
||||
m2m_uids = set(Category.objects.filter(media__in=media).annotate(selected_count=Count('media', distinct=True)).filter(selected_count=media_count).values_list('uid', flat=True))
|
||||
|
||||
# Categories where ANY selected media has an EmbedMediaCourse record
|
||||
embed_uids = set(EmbedMediaCourse.objects.filter(media__in=media).values_list('category__uid', flat=True))
|
||||
|
||||
results = list(Category.objects.filter(uid__in=m2m_uids | embed_uids).values('title', 'uid'))
|
||||
|
||||
return Response({'results': results})
|
||||
|
||||
@@ -574,12 +651,31 @@ class MediaBulkUserActions(APIView):
|
||||
|
||||
elif action == "add_to_category":
|
||||
category_uids = request.data.get('category_uids', [])
|
||||
if not category_uids:
|
||||
return Response({"detail": "category_uids is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
lti_context_id = request.data.get('lti_context_id')
|
||||
|
||||
if not category_uids and not lti_context_id:
|
||||
return Response({"detail": "category_uids or lti_context_id is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
categories = Category.objects.none()
|
||||
|
||||
# Prioritize category_uids
|
||||
if category_uids:
|
||||
categories = Category.objects.filter(uid__in=category_uids)
|
||||
elif lti_context_id:
|
||||
# Filter categories by lti_context_id and ensure they ARE RBAC categories
|
||||
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
|
||||
|
||||
# Check user access (must have contributor access)
|
||||
valid_category_ids = []
|
||||
for cat in potential_categories:
|
||||
if request.user.has_contributor_access_to_category(cat):
|
||||
valid_category_ids.append(cat.id)
|
||||
|
||||
if valid_category_ids:
|
||||
categories = Category.objects.filter(id__in=valid_category_ids)
|
||||
|
||||
categories = Category.objects.filter(uid__in=category_uids)
|
||||
if not categories:
|
||||
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"detail": "No matching categories found or access denied"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
added_count = 0
|
||||
for category in categories:
|
||||
@@ -605,6 +701,9 @@ class MediaBulkUserActions(APIView):
|
||||
if m.category.filter(uid=category.uid).exists():
|
||||
m.category.remove(category)
|
||||
removed_count += 1
|
||||
EmbedMediaCourse.objects.filter(media=m, category=category).delete()
|
||||
if not m.category.filter(is_rbac_category=True).exists() and not m.permissions.exclude(user=m.user).exists():
|
||||
m.permissions.filter(user=m.user).delete()
|
||||
|
||||
return Response({"detail": f"Removed {removed_count} media items from {categories.count()} categories"})
|
||||
|
||||
@@ -647,6 +746,72 @@ class MediaBulkUserActions(APIView):
|
||||
else:
|
||||
return Response({"detail": f"Unknown action: {action}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def _handle_course_cleanup(self, request, media_ids):
|
||||
category_uids = request.data.get('category_uids', [])
|
||||
remove_permissions = request.data.get('remove_permissions', False)
|
||||
remove_comments = request.data.get('remove_comments', False)
|
||||
apply_to_all = request.data.get('apply_to_all', False)
|
||||
|
||||
if not category_uids:
|
||||
return Response({"detail": "category_uids is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
categories = Category.objects.filter(uid__in=category_uids)
|
||||
if not categories.exists():
|
||||
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
valid_categories = [cat for cat in categories if request.user.has_contributor_access_to_category(cat)]
|
||||
if not valid_categories:
|
||||
return Response({"detail": "No contributor access to specified categories"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
has_media = bool(media_ids)
|
||||
selected_media = Media.objects.filter(user=request.user, friendly_token__in=media_ids) if has_media else Media.objects.none()
|
||||
|
||||
for category in valid_categories:
|
||||
# All users who are members of any group linked to this category
|
||||
group_users = User.objects.filter(rbac_groups__in=category.rbac_groups.all()).distinct()
|
||||
|
||||
# Get media explicitly embedded into this course via LTI
|
||||
embed_qs = EmbedMediaCourse.objects.filter(category=category)
|
||||
embedded_media_ids = list(embed_qs.values_list('media_id', flat=True))
|
||||
|
||||
all_course_media = Media.objects.filter(category=category)
|
||||
|
||||
if has_media:
|
||||
selected_embedded = embed_qs.filter(media__in=selected_media)
|
||||
selected_embedded_media_ids = list(selected_embedded.values_list('media_id', flat=True))
|
||||
if remove_permissions:
|
||||
MediaPermission.objects.filter(media__in=selected_media, user__in=group_users).exclude(user=F('media__user')).delete()
|
||||
MediaPermission.objects.filter(media_id__in=selected_embedded_media_ids).exclude(user=F('media__user')).delete()
|
||||
if remove_comments:
|
||||
Comment.objects.filter(media__in=selected_media).delete()
|
||||
|
||||
if apply_to_all:
|
||||
other_course_media = all_course_media.exclude(friendly_token__in=media_ids)
|
||||
other_embedded = embed_qs.exclude(media__in=selected_media)
|
||||
other_embedded_media_ids = list(other_embedded.values_list('media_id', flat=True))
|
||||
if remove_permissions:
|
||||
MediaPermission.objects.filter(media__in=other_course_media, user__in=group_users).exclude(user=F('media__user')).delete()
|
||||
MediaPermission.objects.filter(media_id__in=other_embedded_media_ids).exclude(user=F('media__user')).delete()
|
||||
if remove_comments:
|
||||
Comment.objects.filter(media__in=other_course_media).delete()
|
||||
for m in other_course_media:
|
||||
m.category.remove(category)
|
||||
|
||||
for m in selected_media:
|
||||
m.category.remove(category)
|
||||
else:
|
||||
if remove_permissions:
|
||||
MediaPermission.objects.filter(media__in=all_course_media, user__in=group_users).exclude(user=F('media__user')).delete()
|
||||
MediaPermission.objects.filter(media_id__in=embedded_media_ids).exclude(user=F('media__user')).delete()
|
||||
if remove_comments:
|
||||
Comment.objects.filter(media__in=all_course_media).delete()
|
||||
if embedded_media_ids:
|
||||
Comment.objects.filter(media_id__in=embedded_media_ids).delete()
|
||||
for m in all_course_media:
|
||||
m.category.remove(category)
|
||||
|
||||
return Response({"detail": "Course cleanup completed successfully"})
|
||||
|
||||
|
||||
class MediaDetail(APIView):
|
||||
"""
|
||||
@@ -697,12 +862,9 @@ class MediaDetail(APIView):
|
||||
return media
|
||||
|
||||
serializer = SingleMediaSerializer(media, context={"request": request})
|
||||
if media.state == "private":
|
||||
related_media = []
|
||||
else:
|
||||
related_media = show_related_media(media, request=request, limit=100)
|
||||
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
|
||||
related_media = related_media_serializer.data
|
||||
related_media = show_related_media(media, request=request, limit=100)
|
||||
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
|
||||
related_media = related_media_serializer.data
|
||||
ret = serializer.data
|
||||
|
||||
# update rattings info with user specific ratings
|
||||
@@ -1073,3 +1235,29 @@ class MediaSearch(APIView):
|
||||
page = paginator.paginate_queryset(media, request)
|
||||
serializer = MediaSearchSerializer(page, many=True, context={"request": request})
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def media_share(request, friendly_token):
|
||||
"""Mark a media item as shared when the owner embeds it via the LTI plugin."""
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponse(status=401)
|
||||
|
||||
media = get_object_or_404(Media, friendly_token=friendly_token)
|
||||
if media.user != request.user:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
MediaPermission.objects.get_or_create(
|
||||
media=media,
|
||||
user=request.user,
|
||||
defaults={'owner_user': request.user, 'permission': 'owner'},
|
||||
)
|
||||
|
||||
courseid = request.POST.get('courseid')
|
||||
if courseid:
|
||||
category = Category.objects.filter(lti_context_id=str(courseid), is_rbac_category=True).first()
|
||||
if category:
|
||||
EmbedMediaCourse.objects.get_or_create(media=media, category=category)
|
||||
|
||||
return HttpResponse(status=200)
|
||||
|
||||
+72
-19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user