diff --git a/pod/import_video/admin.py b/pod/import_video/admin.py index 4791ac2528..319a9cdb1b 100644 --- a/pod/import_video/admin.py +++ b/pod/import_video/admin.py @@ -17,6 +17,7 @@ class ExternalRecordingAdmin(admin.ModelAdmin): "start_at", "type", "source_url", + "state", "owner", ) search_fields = [ diff --git a/pod/import_video/forms.py b/pod/import_video/forms.py index e14f8eb61c..9bbbcc9789 100644 --- a/pod/import_video/forms.py +++ b/pod/import_video/forms.py @@ -77,8 +77,8 @@ def __init__(self, *args, **kwargs): self.filter_fields_admin() self.fields = add_placeholder_and_asterisk(self.fields) - # We don't change the user who uploaded the record - hidden_fields = ("uploaded_to_pod_by",) + # We don't change the user who uploaded the record neither the state + hidden_fields = ("uploaded_to_pod_by", "state",) for field in hidden_fields: if self.fields.get(field, None): self.fields[field].widget = forms.HiddenInput() diff --git a/pod/import_video/models.py b/pod/import_video/models.py index ee804661a1..a724b77a0a 100644 --- a/pod/import_video/models.py +++ b/pod/import_video/models.py @@ -102,6 +102,13 @@ class ExternalRecording(models.Model): ), ) + state = models.CharField( + max_length=250, + verbose_name=_("Recording state"), + blank=True, + null=True, + ) + # User who uploaded to Pod the video file uploaded_to_pod_by = models.ForeignKey( User, diff --git a/pod/import_video/templates/import_video/list.html b/pod/import_video/templates/import_video/list.html index f0e9513694..2906f44c75 100644 --- a/pod/import_video/templates/import_video/list.html +++ b/pod/import_video/templates/import_video/list.html @@ -52,7 +52,7 @@ {% for record in recordings %} {{ record.name }} - {{ record.state }} + {{ record.state | safe }} {{ record.startTime }} {{ record.type }} @@ -61,12 +61,13 @@ {% endif %} - {% if record.videoUrl != "" %} + {% if record.videoUrl != "" and record.videoUrl != record.presentationUrl %} {% endif %} - + + {% if record.canUpload %} {% trans "Please confirm you want to upload the recording to Pod" as confirmUpload %}
@@ -76,7 +77,8 @@
{% endif %} - + + {% if record.canDelete %} {% trans "Please confirm you want to delete the external recording" as confirmDelete %} diff --git a/pod/import_video/tests/test_views.py b/pod/import_video/tests/test_views.py index 3d1321069b..93be0a1663 100644 --- a/pod/import_video/tests/test_views.py +++ b/pod/import_video/tests/test_views.py @@ -5,7 +5,10 @@ """ +import os from ..models import ExternalRecording +from ..views import bbb_encode_presentation, bbb_encode_presentation_and_upload_to_pod +from django.conf import settings from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.test import TestCase @@ -14,6 +17,13 @@ from django.utils.translation import ugettext_lazy as _ from http import HTTPStatus +# Directory that will contain the video files generated by bbb-recorder +IMPORT_VIDEO_BBB_RECORDER_PATH = getattr( + settings, + "IMPORT_VIDEO_BBB_RECORDER_PATH", + "/data/bbb-recorder/media/" +) + class ExternalRecordingDeleteTestView(TestCase): """List of view tests for deleting an external recording. @@ -318,3 +328,34 @@ def test_recording_upload_post_request(self): self.assertTrue(unable_str in response.content) print(" ---> test_recording_upload_get_request of RecordingUploadTestView: OK!") + + def test_recording_encode_presentation(self): + """Test recording encode presentation.""" + self.user = User.objects.get(username="pod") + self.client.force_login(self.user) + + # Encode presentation + recordingBBB = ExternalRecording.objects.get(name="test external bbb recording1") + self.user = User.objects.get(username="pod2") + self.client.force_login(self.user) + expected = os.path.join(IMPORT_VIDEO_BBB_RECORDER_PATH, "video.webm") + actual = bbb_encode_presentation( + "video.webm", + recordingBBB.source_url, + ) + self.assertEqual(actual, expected) + # Encode presentation and move to destination + # Expected state + expected = _( + "Impossible to upload to Pod the video, " + "the link provided does not seem valid." + ) + bbb_encode_presentation_and_upload_to_pod( + recordingBBB.id, + recordingBBB.source_url, + "webm" + ) + recordingBBB = ExternalRecording.objects.get(name="test external bbb recording1") + self.assertEqual(recordingBBB.state, expected) + + print(" ---> test_recording_encode_presentation of RecordingUploadTestView: OK!") diff --git a/pod/import_video/utils.py b/pod/import_video/utils.py index 1eac01ffb1..37a7561a23 100644 --- a/pod/import_video/utils.py +++ b/pod/import_video/utils.py @@ -1,4 +1,4 @@ -"""Utils for Meeting and Import_video module.""" +"""Esup-Pod meeting and import_video utils.""" import json import os @@ -7,12 +7,14 @@ from datetime import datetime as dt from django.conf import settings +from django.contrib.auth.models import User from django.utils.html import mark_safe from django.utils.translation import gettext_lazy as _ from html.parser import HTMLParser from pod.video.models import Video from pod.video.models import Type from urllib.parse import parse_qs, urlparse +from requests import Session MAX_UPLOAD_SIZE_ON_IMPORT = getattr(settings, "MAX_UPLOAD_SIZE_ON_IMPORT", 4) @@ -67,7 +69,7 @@ def secure_request_for_upload(request): raise ValueError(msg) -def parse_remote_file(session, source_html_url): +def parse_remote_file(session: Session, source_html_url: str): """Parse the remote HTML file on the BBB server. Args: @@ -140,7 +142,7 @@ def create_parser(response): return parser -def manage_recording_url(source_url, video_file_add): +def manage_recording_url(source_url: str, video_file_add: str) -> str: """Generate the BBB video URL. See more explanations in manage_download() function. @@ -181,7 +183,12 @@ def manage_recording_url(source_url, video_file_add): return source_url + video_file_add -def manage_download(session, source_url, video_file_add, dest_file): +def manage_download( + session: Session, + source_url: str, + video_file_add: str, + dest_file: str +) -> str: """Manage the download of a BBB video file. 2 possibilities : @@ -195,7 +202,7 @@ def manage_download(session, source_url, video_file_add, dest_file): and download_video_file. Args: - session (Session) : session useful to achieve requests (and keep cookies between) + session (Session) : Session useful to achieve requests (and keep cookies between) source_url (String): Source file URL video_file_add (String): Name of the video file to add to the URL dest_file (String): Destination file of the Pod video @@ -214,11 +221,11 @@ def manage_download(session, source_url, video_file_add, dest_file): raise ValueError(mark_safe(str(exc))) -def download_video_file(session, source_video_url, dest_file): +def download_video_file(session: Session, source_video_url: str, dest_file: str): """Download video file. Args: - session (Session) : session useful to achieve requests (and keep cookies between) + session (Session) : Session useful to achieve requests (and keep cookies between) source_video_url (String): Video file URL dest_file (String): Destination file of the Pod video @@ -250,15 +257,21 @@ def download_video_file(session, source_video_url, dest_file): raise ValueError(mark_safe(str(exc))) -def save_video(request, dest_path, recording_name, description, date_evt=None): +def save_video( + user: User, + dest_path: str, + recording_name: str, + description: str, + date_evt=None +): """Save and encode the Pod video file. Args: - request (Request): HTTP request + user (User): User who saved the video dest_path (String): Destination path of the Pod video - recording_name (String): recording name - description (String): description added to the Pod video - date_evt (Datetime, optional): Event date. Defaults to None. + recording_name (String): Recording name + description (String): Description added to the Pod video + date_evt (Datetime, optional): Event date. Default to None Raises: ValueError: if impossible creation @@ -267,7 +280,7 @@ def save_video(request, dest_path, recording_name, description, date_evt=None): video = Video.objects.create( video=dest_path, title=recording_name, - owner=request.user, + owner=user, description=description, is_draft=True, type=Type.objects.get(id=DEFAULT_TYPE_ID), @@ -283,14 +296,14 @@ def save_video(request, dest_path, recording_name, description, date_evt=None): raise ValueError(msg) -def check_file_exists(source_url): - """Check that the URL exists. +def check_url_exists(source_url: str) -> bool: + """Check that the source URL exists. Args: source_url (String): Source URL Returns: - Boolean: file exists (True) or not (False) + Boolean: URL exists (True) or not (False) """ try: response = requests.head(source_url, timeout=2) @@ -302,7 +315,7 @@ def check_file_exists(source_url): return False -def verify_video_exists_and_size(video_url): +def verify_video_exists_and_size(video_url: str): """Check that the video file exists and its size does not exceed the limit. Args: @@ -329,7 +342,7 @@ def verify_video_exists_and_size(video_url): raise ValueError(msg) -def check_video_size(video_size): +def check_video_size(video_size: int): """Check that the video file size does not exceed the limit. Args: @@ -349,12 +362,13 @@ def check_video_size(video_size): raise ValueError(msg) -def check_source_url(source_url): # noqa: C901 +def check_source_url(source_url: str): # noqa: C901 """Check the source URL to identify the used platform. Platforms managed : - Mediacad platform (Médiathèque académique) : rewrite source URL if required and manage JSON API. + - Old BigBlueButton : source URL for old BBB presentation playback """ base_url = "" media_id = "" @@ -421,6 +435,22 @@ def check_source_url(source_url): # noqa: C901 media_id, ) platform = "Mediacad" + elif source_url.find("/playback/presentation/2.0/playback.html?") != -1: + # Old BBB 2.x (<2.3) presentation link + # Conversion from + # https://xxx/playback/presentation/2.0/playback.html?meetingId=ID + # to https://xxx/playback/presentation/2.3/ID?meetingId=ID + media_id = array_url[-1] + source_video_url = source_url.replace( + "/2.0/playback.html", "/2.3/" + media_id + ).replace("playback.html?meetingId=", "") + format = "webm" + platform = "BBB_Presentation" + elif source_url.find("/playback/presentation/2.3/") != -1: + # Old BBB 2.3 presentation link : no conversion needed + source_video_url = source_url + format = "webm" + platform = "BBB_Presentation" # Platform's URL identified if platform == "Mediacad": @@ -431,6 +461,11 @@ def check_source_url(source_url): # noqa: C901 platform_type = TypeSourceURL( platform, source_video_url, format, url_api_video ) + if platform == "BBB_Presentation": + # Platform type: older BBB, format presentation + platform_type = TypeSourceURL( + platform, source_video_url, format, "" + ) return platform_type except Exception as exc: @@ -440,30 +475,45 @@ def check_source_url(source_url): # noqa: C901 raise ValueError(msg) -def define_dest_file(request, id, extension): - """Define standard destination filename for an external recording.""" +def define_dest_file_and_path(user: User, id: str, extension: str): + """Define standard destination filename and path for an external recording.""" # Set a discriminant discrim = dt.now().strftime("%Y%m%d%H%M%S") dest_file = os.path.join( settings.MEDIA_ROOT, VIDEOS_DIR, - request.user.owner.hashkey, + user.owner.hashkey, os.path.basename("%s-%s.%s" % (discrim, id, extension)), ) - os.makedirs(os.path.dirname(dest_file), exist_ok=True) - return dest_file - - -def define_dest_path(request, id, extension): - """Define standard destination path for an external recording.""" - # Set a discriminant - discrim = dt.now().strftime("%Y%m%d%H%M%S") dest_path = os.path.join( VIDEOS_DIR, - request.user.owner.hashkey, + user.owner.hashkey, os.path.basename("%s-%s.%s" % (discrim, id, extension)), ) - return dest_path + os.makedirs(os.path.dirname(dest_file), exist_ok=True) + return dest_file, dest_path + + +def check_file_exists(source: str) -> bool: + """Check that a local file exists.""" + if os.path.exists(source): + return True + else: + return False + + +def move_file(source: str, destination: str): + """Move a file from a source to another destination.""" + try: + # Ensure that the source file exists + if not check_file_exists(source): + print(f"The source file '{source}' does not exist.") + return + + # Move the file to the destination directory + shutil.move(source, destination) + except Exception as e: + print(f"Error moving the file: {e}") class TypeSourceURL: diff --git a/pod/import_video/views.py b/pod/import_video/views.py index 9303c17511..5456cf9c5e 100644 --- a/pod/import_video/views.py +++ b/pod/import_video/views.py @@ -1,22 +1,29 @@ -"""Views of the Import_video module.""" - +"""Esup-Pod import_video views. +More information on this module at: https://www.esup-portail.org/wiki/x/BQCnSw +""" +import logging import os import requests +import subprocess +import threading +import time # For PeerTube download import json from .models import ExternalRecording from .forms import ExternalRecordingForm -from .utils import StatelessRecording, check_file_exists, download_video_file +from .utils import StatelessRecording, check_url_exists, download_video_file from .utils import manage_recording_url, check_source_url, parse_remote_file from .utils import save_video, secure_request_for_upload from .utils import check_video_size, verify_video_exists_and_size -from .utils import define_dest_file, define_dest_path +from .utils import define_dest_file_and_path, check_file_exists, move_file +from .utils import TypeSourceURL from datetime import datetime from django.conf import settings -from django.contrib.sites.shortcuts import get_current_site from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import PermissionDenied from django.contrib import messages from django.shortcuts import render, redirect @@ -35,6 +42,10 @@ from pytube import YouTube from pytube.exceptions import PytubeError, VideoUnavailable +# To convert old BBB presentation +from pod.main.tasks import task_start_bbb_presentation_encode_and_upload_to_pod +from pod.main.tasks import task_start_bbb_presentation_encode_and_move_to_destination + RESTRICT_EDIT_IMPORT_VIDEO_ACCESS_TO_STAFF_ONLY = getattr( settings, "RESTRICT_EDIT_IMPORT_VIDEO_ACCESS_TO_STAFF_ONLY", True @@ -70,8 +81,31 @@ VIDEOS_DIR = getattr(settings, "VIDEOS_DIR", "videos") +# Use of Celery to encode +CELERY_TO_ENCODE = getattr(settings, "CELERY_TO_ENCODE", False) +# Use plugin bbb-recorder for import-video module +USE_IMPORT_VIDEO_BBB_RECORDER = getattr( + settings, + "USE_IMPORT_VIDEO_BBB_RECORDER", + False +) +# Directory of bbb-recorder plugin +IMPORT_VIDEO_BBB_RECORDER_PLUGIN = getattr( + settings, + "IMPORT_VIDEO_BBB_RECORDER_PLUGIN", + "/data/bbb-recorder/" +) +# Directory that will contain the video files generated by bbb-recorder +IMPORT_VIDEO_BBB_RECORDER_PATH = getattr( + settings, + "IMPORT_VIDEO_BBB_RECORDER_PATH", + "/data/bbb-recorder/media/" +) -def secure_external_recording(request, recording): +log = logging.getLogger(__name__) + + +def secure_external_recording(request, recording: ExternalRecording): """Secure an external recording. Args: @@ -95,7 +129,7 @@ def secure_external_recording(request, recording): raise PermissionDenied -def get_can_delete_external_recording(request, owner): +def get_can_delete_external_recording(request, owner: User): """Return True if current user can delete this recording.""" can_delete = False @@ -112,7 +146,7 @@ def get_can_delete_external_recording(request, owner): @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def upload_external_recording_to_pod(request, record_id): +def upload_external_recording_to_pod(request, record_id: int): """Upload external recording to Pod. Args: @@ -284,7 +318,7 @@ def add_or_edit_external_recording(request, id=None): @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def delete_external_recording(request, id): +def delete_external_recording(request, id: int): """Delete an external recording. Args: @@ -353,11 +387,11 @@ def save_recording_form(request, form): # ############################## Upload recordings to Pod -def save_external_recording(request, record_id): +def save_external_recording(user: User, record_id: int): """Save an external recording in database. Args: - request (Request): HTTP request + user (User): user who saved this recording record_id (Integer): id of the recording in database Raises: @@ -367,7 +401,7 @@ def save_external_recording(request, record_id): # Update the external recording recording, created = ExternalRecording.objects.update_or_create( id=record_id, - defaults={"uploaded_to_pod_by": request.user}, + defaults={"uploaded_to_pod_by": user}, ) except Exception as exc: msg = {} @@ -376,7 +410,7 @@ def save_external_recording(request, record_id): raise ValueError(msg) -def upload_recording_to_pod(request, record_id): +def upload_recording_to_pod(request, record_id: int) -> bool: """Upload recording to Pod (main function). Args: @@ -402,7 +436,7 @@ def upload_recording_to_pod(request, record_id): elif recording.type == "peertube": return upload_peertube_recording_to_pod(request, record_id) else: - # Upload a standard or BBB video file + # Upload a standard or BBB video file, or a BBB presentation return upload_video_recording_to_pod(request, record_id) except Exception as exc: msg = {} @@ -421,17 +455,30 @@ def upload_recording_to_pod(request, record_id): raise ValueError(msg) -def upload_video_recording_to_pod(request, record_id): - """Upload a standard or BBB video file to Pod.""" +def upload_video_recording_to_pod(request, record_id: int): # noqa: C901 + """Upload a standard or BBB video file, or a BBB presentation to Pod.""" try: - # Try to identify the platform (avoids multiple source types) + # Try to identify the type of the source URL (avoids multiple source types) type_source_url = check_source_url(request.POST.get("source_url")) - # Mediacad platform if type_source_url is not None and type_source_url.type == "Mediacad": + # Mediacad platform return upload_mediacad_recording_to_pod(request, record_id, type_source_url) + elif type_source_url is not None and type_source_url.type == "BBB_Presentation": + # Old BigBlueBlutton playback (presentation format) : + # convert this presentation in video and upload automatically to Pod + # via an asynchronous task + if USE_IMPORT_VIDEO_BBB_RECORDER: + start_bbb_encode_presentation_and_upload_to_pod( + record_id, + type_source_url.url, + type_source_url.extension + ) + return True + else: + return False else: # Video file (or BBB video file, same process) source URL - return upload_bbb_recording_to_pod(request, record_id) + return upload_standard_video_recording_to_pod(record_id) except Exception as exc: msg = {} proposition = "" @@ -449,11 +496,11 @@ def upload_video_recording_to_pod(request, record_id): raise ValueError(msg) -def upload_bbb_recording_to_pod(request, record_id): - """Upload a video file (or BBB video file) recording to Pod. +def upload_standard_video_recording_to_pod(record_id: int) -> bool: + """Upload a standard video file (or BBB video file) recording to Pod. + Used with an URL. Args: - request (Request): HTTP request record_id (Integer): id record in the database Raises: @@ -467,11 +514,12 @@ def upload_bbb_recording_to_pod(request, record_id): session = requests.Session() recording = ExternalRecording.objects.get(id=record_id) - source_url = request.POST.get("source_url") # Step 1: Download and parse the remote HTML file if necessary # Check if extension is a video extension + source_url = recording.source_url extension = source_url.split(".")[-1].lower() + # Name of the video file to add to the URL (if necessary) video_file_add = "" if extension not in VIDEO_ALLOWED_EXTENSIONS: @@ -480,21 +528,28 @@ def upload_bbb_recording_to_pod(request, record_id): # Extension overload extension = video_file_add.split(".")[-1].lower() - # Verify that video exists and not oversised + # Verify that video exists and not oversized source_video_url = manage_recording_url(source_url, video_file_add) verify_video_exists_and_size(source_video_url) # Step 2: Define destination source file extension = source_video_url.split(".")[-1].lower() - dest_file = define_dest_file(request, recording.id, extension) - dest_path = define_dest_path(request, recording.id, extension) + dest_file, dest_path = define_dest_file_and_path( + recording.owner, + recording.id, + extension + ) # Step 3: Download the video file - source_video_url = manage_download(session, source_url, video_file_add, dest_file) + source_video_url = manage_download( + session, + source_url, + video_file_add, + dest_file + ) # Step 4: Save informations about the recording - recording_title = request.POST.get("recording_name") - save_external_recording(request, record_id) + save_external_recording(recording.owner, record_id) # Step 5: Save and encode Pod video description = _( @@ -502,27 +557,55 @@ def upload_bbb_recording_to_pod(request, record_id): '%(url)s' ) % {"type": recording.get_type_display(), "url": source_video_url} - save_video(request, dest_path, recording_title, description) + save_video(recording.owner, dest_path, recording.name, description) return True except Exception as exc: - msg = {} - msg["error"] = _("Impossible to upload to Pod the video") - try: - # Management of error messages from sub-functions - message = "%s %s" % (exc.args[0]["error"], exc.args[0]["message"]) - except Exception: - # Management of error messages in all cases - message = str(exc) + manage_standard_exception(exc) - msg["message"] = mark_safe(message) - msg["proposition"] = _( - "Try changing the record type or address for this recording." - ) - raise ValueError(msg) +def upload_local_video_recording_to_pod(record_id: id, dest_file: str, dest_path: str): + """Upload a local (typically in Pod filesystem) video file recording to Pod. + Useful for video files that have been encoded following + the recording of a BBB presentation. + + Args: + record_id (Integer): id record in the database + dest_file (String): destination filename + dest_path (String): destination path + + Raises: + ValueError: exception raised if no video found in the filesystem + + Returns: + Boolean: True if upload achieved + """ + try: + recording = ExternalRecording.objects.get(id=record_id) + + # Step 1: Save informations about the recording + save_external_recording(recording.owner, record_id) + + # Step 2: Save and encode Pod video + description = _( + "This video was uploaded to Pod; its origin is %(type)s: " + '%(url)s' + ) % {"type": recording.get_type_display(), "url": recording.source_url} + + save_video(recording.owner, dest_path, recording.name, description) + + log.info("- Video file uploaded: %s" % dest_file) + + return True + except Exception as exc: + manage_standard_exception(exc) -def upload_mediacad_recording_to_pod(request, record_id, type_source_url): + +def upload_mediacad_recording_to_pod( + request, + record_id: int, + type_source_url: TypeSourceURL +): """Upload a Mediacad video file recording to Pod. Args: @@ -551,40 +634,30 @@ def upload_mediacad_recording_to_pod(request, record_id, type_source_url): verify_video_exists_and_size(source_video_url) # Step 2: Define destination source file - dest_file = define_dest_file(request, recording.id, extension) - dest_path = define_dest_path(request, recording.id, extension) + dest_file, dest_path = define_dest_file_and_path( + recording.owner, + recording.id, + extension + ) # Step 3: Download the video file download_video_file(session, source_video_url, dest_file) # Step 4: Save informations about the recording recording_title = request.POST.get("recording_name") - save_external_recording(request, record_id) + save_external_recording(recording.owner, record_id) # Step 5: Save and encode Pod video # Get description from JSON API description = get_mediacad_api_description(type_source_url) - save_video(request, dest_path, recording_title, description) + save_video(recording.owner, dest_path, recording_title, description) return True except Exception as exc: - msg = {} - msg["error"] = _("Impossible to upload to Pod the video") - try: - # Management of error messages from sub-functions - message = "%s %s" % (exc.args[0]["error"], exc.args[0]["message"]) - except Exception: - # Management of error messages in all cases - message = str(exc) - - msg["message"] = mark_safe(message) - msg["proposition"] = _( - "Try changing the record type or address for this recording." - ) - raise ValueError(msg) + manage_standard_exception(exc) -def get_mediacad_api_description(type_source_url): +def get_mediacad_api_description(type_source_url: TypeSourceURL) -> str: """Returns description of a Mediacad video, after a call to Mediacad JSON API. Args: @@ -618,7 +691,7 @@ def get_mediacad_api_description(type_source_url): return description -def upload_youtube_recording_to_pod(request, record_id): +def upload_youtube_recording_to_pod(request, record_id: int): """Upload Youtube recording to Pod. Use PyTube with its API @@ -675,7 +748,7 @@ def upload_youtube_recording_to_pod(request, record_id): yt_stream.download(dest_dir, filename=filename) # Step 4: Save informations about the recording - save_external_recording(request, record_id) + save_external_recording(request.user, record_id) # Step 5: Save and encode Pod video description = _( @@ -683,7 +756,7 @@ def upload_youtube_recording_to_pod(request, record_id): 'its origin is Youtube: %(url)s' ) % {"name": yt_video.title, "url": source_url} recording_title = request.POST.get("recording_name") - save_video(request, dest_path, recording_title, description, date_evt) + save_video(request.user, dest_path, recording_title, description, date_evt) return True except VideoUnavailable: @@ -707,23 +780,10 @@ def upload_youtube_recording_to_pod(request, record_id): msg["proposition"] = _("Try changing the address of this recording.") raise ValueError(msg) except Exception as exc: - msg = {} - msg["error"] = _("Impossible to upload to Pod the video") - try: - # Management of error messages from sub-functions - message = "%s %s" % (exc.args[0]["error"], exc.args[0]["message"]) - except Exception: - # Management of error messages in all cases - message = str(exc) - - msg["message"] = mark_safe(message) - msg["proposition"] = _( - "Try changing the record type or address for this recording." - ) - raise ValueError(msg) + manage_standard_exception(exc) -def upload_peertube_recording_to_pod(request, record_id): # noqa: C901 +def upload_peertube_recording_to_pod(request, record_id: int) -> bool: # noqa: C901 """Upload Peertube recording to Pod. More information: https://docs.joinpeertube.org/api/rest-getting-started @@ -812,15 +872,18 @@ def upload_peertube_recording_to_pod(request, record_id): # noqa: C901 # Step 2: Define destination source file extension = source_video_url.split(".")[-1].lower() - dest_file = define_dest_file(request, pt_video_uuid, extension) - dest_path = define_dest_path(request, pt_video_uuid, extension) + dest_file, dest_path = define_dest_file_and_path( + request.user, + pt_video_uuid, + extension + ) # Step 3: Download the video file download_video_file(session, source_video_url, dest_file) # Step 4: Save informations about the recording recording_title = request.POST.get("recording_name") - save_external_recording(request, record_id) + save_external_recording(request.user, record_id) # Step 5: Save and encode Pod video description = _( @@ -828,27 +891,215 @@ def upload_peertube_recording_to_pod(request, record_id): # noqa: C901 "%(url)s." ) % {"name": pt_video_name, "url": pt_video_url} description = ("%s
%s") % (description, pt_video_description) - save_video(request, dest_path, recording_title, description, date_evt) + save_video(request.user, dest_path, recording_title, description, date_evt) return True except Exception as exc: - msg = {} - msg["error"] = _("Impossible to upload to Pod the PeerTube video") - try: - # Management of error messages from sub-functions - message = "%s %s" % (exc.args[0]["error"], exc.args[0]["message"]) - except Exception: - # Management of error messages in all cases - message = str(exc) + manage_standard_exception(exc) - msg["message"] = mark_safe(message) - msg["proposition"] = _( - "Try changing the record type or address for this recording." + +def start_bbb_encode_presentation_and_upload_to_pod( + record_id: int, + url: str, + extension: str +): + """Send an asynchronous task or a thread to encode a BBB presentation + into a video file and upload it to Pod. + + With Celery, logs can be found in encoding servers, worker.log. + Without Celery, a thread is run to do the job. + """ + if CELERY_TO_ENCODE: + task_start_bbb_presentation_encode_and_upload_to_pod.delay( + record_id, + url, + extension ) - raise ValueError(msg) + else: + log.info( + "START BBB ENCODE PRESENTATION/UPLOAD FOR EXTERNAL RECORDING %s" % record_id + ) + # Thread use + t = threading.Thread( + target=bbb_encode_presentation_and_upload_to_pod, + args=[record_id, url, extension] + ) + t.setDaemon(True) + t.start() + + +def start_bbb_encode_presentation_and_move_to_destination( + filename: str, + url: str, + dest_file: str +): + """Send an asynchronous task to encode or encode direclty a BBB presentation + into a video file and move it to a specific directory. + + With Celery, logs can be found in encoding servers, worker.log. + Without Celery, we didn't use a thread because it caused problems + for the script importing old BBB records. + """ + if CELERY_TO_ENCODE: + task_start_bbb_presentation_encode_and_move_to_destination.delay( + filename, + url, + dest_file + ) + else: + log.info("START BBB ENCODE PRESENTATION/MOVE FOR %s" % filename) + # No thread here + bbb_encode_presentation_and_move_to_destination(filename, url, dest_file) -def get_stateless_recording(request, data): +def bbb_encode_presentation(filename: str, url: str) -> str: + """Encode a BBB presentation into a video file. + + Use bbb-recorder github project to do this. + The logs for this operation can be found in + IMPORT_VIDEO_BBB_RECORDER_PATH/logs/record_id.log. + The video file is generated in IMPORT_VIDEO_BBB_RECORDER_PATH/. + """ + # Logs + logname = "%s.log" % filename + logpath = os.path.join( + IMPORT_VIDEO_BBB_RECORDER_PATH, + "logs", + logname + ) + + # The command looks like: + # node export.js https://bbb.univ.fr/playback/presentation/2.0/ + # playback.html?meetingId=INTERNAL_MEETING_ID INTERNAL_MEETING_ID.webm + # > /data/www/USERPOD/bbb-recorder/logs/INTERNAL_MEETING_ID.log + # 2>&1 < /dev/null + # Recording_URL can be also like https://bbb.univ.fr/playback/presentation/2.3/ + # INTERNAL_MEETING_ID/?meetingId=INTERNAL_MEETING_ID/ + # Encode the presentation in webm (not mp4, less data in log) + # Put the generated video file on the bbb-recorder plugin directory + command = "cd %s; node export.js %s %s > %s 2>&1 < /dev/null" % ( + IMPORT_VIDEO_BBB_RECORDER_PLUGIN, + url, + filename, + logpath + ) + log.info("- bbb_encode_presentation") + log.info("- Command: %s" % command) + log.info("- Start encoding: %s" % time.ctime()) + + # Execute the process + result = subprocess.run( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT + ) + log.info("- Process result: %s" % result) + log.info("- End encoding: %s" % time.ctime()) + + source_generated_file = os.path.join( + IMPORT_VIDEO_BBB_RECORDER_PATH, + filename, + ) + log.info("- Generated file: %s" % source_generated_file) + # returns the path and filename of the generated video file + return source_generated_file + + +def bbb_encode_presentation_and_move_to_destination( + filename: str, + url: str, + dest_file: str +): + """Encode a BBB presentation into a video file and put it on a specific directory. + + Use bbb-recorder github project to do this. + The logs for this operation can be found in IMPORT_VIDEO_BBB_RECORDER_PATH/logs/.log. + The video file is generated in IMPORT_VIDEO_BBB_RECORDER_PATH/ then moved + to the destination (dest_file). + """ + # Achieve the encode of the presentation via bbb-recorder + source_generated_file = bbb_encode_presentation(filename, url) + + if check_file_exists(source_generated_file): + log.info("- Video file generated: %s" % source_generated_file) + + # Move generated video file into the good directory + move_file(source_generated_file, dest_file) + + log.info("- Video file moved to: %s" % dest_file) + else: + log.error("- Video file not generated: %s" % source_generated_file) + + +def bbb_encode_presentation_and_upload_to_pod(record_id: int, url: str, extension: str): + """Encode a BBB presentation into a video file and upload it to Pod. + + Use bbb-recorder github project to do this. + The logs for this operation can be found in + IMPORT_VIDEO_BBB_RECORDER_PATH/logs/record_id.log. + The video file is generated in IMPORT_VIDEO_BBB_RECORDER_PATH/ then moved + to the user directory and finally saved and encoded in Pod. + """ + # Get the record + recording = ExternalRecording.objects.get(id=record_id) + filename = str(record_id) + "." + extension + + dest_file, dest_path = define_dest_file_and_path( + recording.owner, + recording.id, + extension + ) + # Change the recording state + recording.state = _( + "Convert web presentation to video in progress..." + ) + recording.save() + + # Achieve the encode of the presentation via bbb-recorder + source_generated_file = bbb_encode_presentation(filename, url) + + if check_file_exists(source_generated_file): + log.info("- Video file generated: %s" % source_generated_file) + + # Move generated video file into the good directory + move_file(source_generated_file, dest_file) + + log.info("- Video file moved to: %s" % dest_file) + + # Finally upload the generated video file to Pod + if upload_local_video_recording_to_pod(record_id, dest_file, dest_path): + recording.state = _("Video file already uploaded to Pod") + recording.save() + + else: + # Video file not generated : inform the user via the recording state + recording.state = _( + "Impossible to upload to Pod the video, " + "the link provided does not seem valid." + ) + recording.save() + + +def manage_standard_exception(exc: Exception): + """Manage standard exception to raise a specific error.""" + msg = {} + msg["error"] = _("Impossible to upload to Pod the video") + try: + # Management of error messages from sub-functions + message = "%s %s" % (exc.args[0]["error"], exc.args[0]["message"]) + except Exception: + # Management of error messages in all cases + message = str(exc) + + msg["message"] = mark_safe(message) + msg["proposition"] = _( + "Try changing the record type or address for this recording." + ) + raise ValueError(msg) + + +def get_stateless_recording(request, data: ExternalRecording): """Return a stateless recording from an external recording. Args: @@ -858,7 +1109,7 @@ def get_stateless_recording(request, data): Returns: StatelessRecording: stateless recording """ - recording = StatelessRecording(data.id, data.name, "published") + recording = StatelessRecording(data.id, data.name, None) # By default, upload to Pod is possible recording.canUpload = True # Only owner can delete this external recording @@ -868,33 +1119,29 @@ def get_stateless_recording(request, data): recording.uploadedToPodBy = data.uploaded_to_pod_by - # State management - if recording.uploadedToPodBy is None: - recording.state = _("Video file not uploaded to Pod") - else: - recording.state = _("Video file already uploaded to Pod") + # Status management + recording.state = get_status_recording(data) # Management of the external recording type if data.type == "bigbluebutton": # Manage BBB recording URL - video_url = data.source_url - # For BBB, external URL can be the video or presentation playback - if video_url.find("playback/video") != -1: - # Management for standards video URLs with BBB or Scalelite server - recording.videoUrl = video_url - elif video_url.find("playback/presentation/2.3") != -1: - # Management for standards presentation URLs with BBB or Scalelite server - # Add computed video playback - recording.videoUrl = video_url.replace( - "playback/presentation/2.3", "playback/video" - ) - recording.presentationUrl = video_url + # Management for old presentation URLs with BBB or Scalelite server + if data.source_url.find("/playback/presentation/2.0/playback.html?") != -1: + recording.presentationUrl = data.source_url + # Management for standard presentation URLs with BBB or Scalelite server + elif data.source_url.find("/playback/presentation/2.3/") != -1: + recording.presentationUrl = data.source_url + # Management of other situations else: - # Management of other situations, non standards URLs - recording.videoUrl = video_url - - # For old BBB or BBB 2.6+ without video playback - if check_file_exists(recording.videoUrl) is False: + recording.videoUrl = data.source_url + + # If possible to encode BBB presentation + # Useful for old BBB or BBB 2.6+ without video playback + if USE_IMPORT_VIDEO_BBB_RECORDER and recording.presentationUrl != "": + recording.canUpload = True + recording.videoUrl = recording.presentationUrl + # In all case + if recording.videoUrl == "" or check_url_exists(recording.videoUrl) is False: recording.state = _( "No video file found. Upload to Pod as a video is not possible." ) @@ -908,3 +1155,14 @@ def get_stateless_recording(request, data): recording.type = data.get_type_display return recording + + +def get_status_recording(data: ExternalRecording) -> str: + """Get the status of an external recording.""" + if data.uploaded_to_pod_by is None and data.state is None: + state = _("Video file not uploaded to Pod") + elif data.uploaded_to_pod_by is not None and data.state is None: + state = _("Video file already uploaded to Pod") + else: + state = data.state + return state diff --git a/pod/locale/fr/LC_MESSAGES/django.po b/pod/locale/fr/LC_MESSAGES/django.po index a4789b6812..5e03a9fc4f 100644 --- a/pod/locale/fr/LC_MESSAGES/django.po +++ b/pod/locale/fr/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-02-15 10:00+0000\n" +"POT-Creation-Date: 2024-02-15 17:43+0100\n" "PO-Revision-Date: \n" "Last-Translator: ptitloup \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -1091,26 +1091,6 @@ msgstr "Les chapitres ne peuvent commencer au même moment." msgid "You must save your chapters to view the result." msgstr "Vous devez sauvegarder vos chapitres pour voir le résultat." -#: pod/chapter/templates/video_chapter.html -#: pod/live/templates/live/event_edit.html -#: pod/live/templates/live/event_immediate_edit.html -#: pod/main/templates/contact_us.html -#: pod/video/templates/channel/channel_edit.html -#: pod/video/templates/channel/theme_edit.html -#: pod/video/templates/videos/video_edit.html -msgid "Mandatory fields" -msgstr "Champs obligatoires" - -#: pod/chapter/templates/video_chapter.html -#: pod/live/templates/live/event_edit.html -#: pod/live/templates/live/event_immediate_edit.html -#: pod/main/templates/contact_us.html -#: pod/video/templates/channel/channel_edit.html -#: pod/video/templates/channel/theme_edit.html -#: pod/video/templates/videos/video_edit.html -msgid "Fields marked with an asterisk are mandatory." -msgstr "Les champs marqués avec un astérisque sont obligatoires." - #: pod/chapter/views.py msgid "You cannot chapter this video." msgstr "Vous ne pouvez chapitrer cette vidéo." @@ -2557,6 +2537,10 @@ msgstr "" "Veuillez entrer l’adresse de l’enregistrement à télécharger. Cette adresse " "doit correspondre au type d’enregistrement sélectionné." +#: pod/import_video/models.py +msgid "Recording state" +msgstr "Etat de l’enregistrement" + #: pod/import_video/models.py pod/meeting/models.py msgid "User who uploaded to Pod the video file" msgstr "Utilisateur qui a téléversé le fichier vidéo sur Pod" @@ -2854,6 +2838,13 @@ msgstr "Impossible de téléverser la vidéo sur Pod" msgid "YouTube content is inaccessible." msgstr "Ce contenu de YouTube est inaccessible." +#: pod/import_video/tests/test_views.py pod/import_video/views.py +msgid "" +"Impossible to upload to Pod the video, the link provided does not seem valid." +msgstr "" +"Impossible de télécharger sur Pod la vidéo, le lien fourni ne semble pas " +"valide." + #: pod/import_video/utils.py msgid "No URL found / No recording name" msgstr "Aucune URL trouvée / Aucun nom d’enregistrement" @@ -3048,25 +3039,31 @@ msgstr "" "PeerTube : %(url)s." #: pod/import_video/views.py -msgid "Impossible to upload to Pod the PeerTube video" -msgstr "Impossible de téléverser la vidéo PeerTube sur Pod" - -#: pod/import_video/views.py -#: pod/meeting/templates/meeting/internal_recordings.html -msgid "Video file not uploaded to Pod" -msgstr "Le fichier vidéo n’a pas été téléversé sur Pod" +msgid "Convert web presentation to video in progress..." +msgstr "Conversion de la présentation web en vidéo en cours..." #: pod/import_video/views.py #: pod/meeting/templates/meeting/internal_recordings.html msgid "Video file already uploaded to Pod" msgstr "Fichier vidéo déjà téléversé sur Pod" +#: pod/import_video/views.py pod/meeting/views.py +msgid "Try changing the record type or address for this recording." +msgstr "" +"Essayez de modifier le type d’enregistrement ou l’adresse de cet " +"enregistrement." + #: pod/import_video/views.py msgid "No video file found. Upload to Pod as a video is not possible." msgstr "" "Aucun fichier vidéo trouvé. Le téléversement sur Pod en tant que vidéo n’est " "pas possible." +#: pod/import_video/views.py +#: pod/meeting/templates/meeting/internal_recordings.html +msgid "Video file not uploaded to Pod" +msgstr "Le fichier vidéo n’a pas été téléversé sur Pod" + #: pod/live/admin.py pod/live/models.py msgid "QR code to record immediately an event" msgstr "QR code pour la programmation d’un évènement immédiat" @@ -4991,6 +4988,14 @@ msgstr "Vous venez d’envoyer un message depuis" msgid "Regards" msgstr "Cordialement" +#: pod/main/templates/main/mandatory_fields.html +msgid "Mandatory fields" +msgstr "Champs obligatoires" + +#: pod/main/templates/main/mandatory_fields.html +msgid "Fields marked with an asterisk are mandatory." +msgstr "Les champs marqués avec un astérisque sont obligatoires." + #: pod/main/templates/maintenance.html msgid "" "The maintenance is over, you can go back to work. Thank you for your " @@ -7150,6 +7155,10 @@ msgstr "Définir comme brouillon" msgid "Transcript selected" msgstr "Transcrire la sélection" +#: pod/video/admin.py +msgid "Change video owner" +msgstr "Changer le propriétaire de vidéos" + #: pod/video/forms.py msgid "File field" msgstr "Fichier" @@ -8107,10 +8116,6 @@ msgstr "Supprimer la catégorie" msgid "Permanently delete your category?" msgstr "Supprimer définitivement votre catégorie ?" -#: pod/video/templates/videos/change_video_owner.html -msgid "Change video owner" -msgstr "Changer le propriétaire de vidéos" - #: pod/video/templates/videos/change_video_owner.html msgid "Old owner" msgstr "Ancien propriétaire" @@ -9251,30 +9256,6 @@ msgstr "Résultats de la recherche" msgid "Esup-Pod xAPI" msgstr "xAPI Esup-Pod" -#~ msgid "Use this link to share the video in draft mode" -#~ msgstr "Utiliser ce lien pour partager la vidéo en mode brouillon" - -#~ msgid "YouTube content is inaccessible" -#~ msgstr "Le contenu YouTube n’est pas disponible" - -#~ msgid "" -#~ "YouTube content is inaccessible. This content does not appear to be " -#~ "publicly available." -#~ msgstr "" -#~ "Le contenu YouTube est inaccessible. Ce contenu ne semble pas être " -#~ "disponible publiquement." - -#~ msgid "" -#~ "Access to adding external recording has been restricted. If you want to " -#~ "add external recordings on the platform, please" -#~ msgstr "" -#~ "L’accès à l’ajout d’un enregistrement externe a été restreint. Si vous " -#~ "souhaitez ajouter des enregistrements externes sur la plateforme, s’il " -#~ "vous plaît" - -#~ msgid "Deleting the record" -#~ msgstr "Supprimer cet enregistrement" - #~ msgid "Playlist image" #~ msgstr "Image de la playlist" diff --git a/pod/locale/fr/LC_MESSAGES/djangojs.po b/pod/locale/fr/LC_MESSAGES/djangojs.po index 64dfced9b5..fe3862991f 100644 --- a/pod/locale/fr/LC_MESSAGES/djangojs.po +++ b/pod/locale/fr/LC_MESSAGES/djangojs.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Esup-Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-02-15 10:00+0000\n" +"POT-Creation-Date: 2024-02-15 17:43+0100\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: \n" diff --git a/pod/locale/nl/LC_MESSAGES/django.po b/pod/locale/nl/LC_MESSAGES/django.po index 934c0473cd..df58e7ec83 100644 --- a/pod/locale/nl/LC_MESSAGES/django.po +++ b/pod/locale/nl/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-02-15 10:00+0000\n" +"POT-Creation-Date: 2024-02-15 17:43+0100\n" "PO-Revision-Date: 2023-06-08 14:37+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -1028,26 +1028,6 @@ msgstr "" msgid "You must save your chapters to view the result." msgstr "" -#: pod/chapter/templates/video_chapter.html -#: pod/live/templates/live/event_edit.html -#: pod/live/templates/live/event_immediate_edit.html -#: pod/main/templates/contact_us.html -#: pod/video/templates/channel/channel_edit.html -#: pod/video/templates/channel/theme_edit.html -#: pod/video/templates/videos/video_edit.html -msgid "Mandatory fields" -msgstr "" - -#: pod/chapter/templates/video_chapter.html -#: pod/live/templates/live/event_edit.html -#: pod/live/templates/live/event_immediate_edit.html -#: pod/main/templates/contact_us.html -#: pod/video/templates/channel/channel_edit.html -#: pod/video/templates/channel/theme_edit.html -#: pod/video/templates/videos/video_edit.html -msgid "Fields marked with an asterisk are mandatory." -msgstr "" - #: pod/chapter/views.py msgid "You cannot chapter this video." msgstr "" @@ -2433,6 +2413,10 @@ msgid "" "match the record type selected." msgstr "" +#: pod/import_video/models.py +msgid "Recording state" +msgstr "" + #: pod/import_video/models.py pod/meeting/models.py msgid "User who uploaded to Pod the video file" msgstr "" @@ -2687,6 +2671,11 @@ msgstr "" msgid "YouTube content is inaccessible." msgstr "" +#: pod/import_video/tests/test_views.py pod/import_video/views.py +msgid "" +"Impossible to upload to Pod the video, the link provided does not seem valid." +msgstr "" + #: pod/import_video/utils.py msgid "No URL found / No recording name" msgstr "" @@ -2781,10 +2770,6 @@ msgid "" "target=\"_blank\">%(url)s" msgstr "" -#: pod/import_video/views.py pod/meeting/views.py -msgid "Try changing the record type or address for this recording." -msgstr "" - #: pod/import_video/views.py #, python-format msgid "" @@ -2856,23 +2841,27 @@ msgid "" msgstr "" #: pod/import_video/views.py -msgid "Impossible to upload to Pod the PeerTube video" +msgid "Convert web presentation to video in progress..." msgstr "" #: pod/import_video/views.py #: pod/meeting/templates/meeting/internal_recordings.html -msgid "Video file not uploaded to Pod" +msgid "Video file already uploaded to Pod" msgstr "" -#: pod/import_video/views.py -#: pod/meeting/templates/meeting/internal_recordings.html -msgid "Video file already uploaded to Pod" +#: pod/import_video/views.py pod/meeting/views.py +msgid "Try changing the record type or address for this recording." msgstr "" #: pod/import_video/views.py msgid "No video file found. Upload to Pod as a video is not possible." msgstr "" +#: pod/import_video/views.py +#: pod/meeting/templates/meeting/internal_recordings.html +msgid "Video file not uploaded to Pod" +msgstr "" + #: pod/live/admin.py pod/live/models.py msgid "QR code to record immediately an event" msgstr "" @@ -4727,6 +4716,14 @@ msgstr "" msgid "Regards" msgstr "" +#: pod/main/templates/main/mandatory_fields.html +msgid "Mandatory fields" +msgstr "" + +#: pod/main/templates/main/mandatory_fields.html +msgid "Fields marked with an asterisk are mandatory." +msgstr "" + #: pod/main/templates/maintenance.html msgid "" "The maintenance is over, you can go back to work. Thank you for your " @@ -6735,6 +6732,10 @@ msgstr "" msgid "Transcript selected" msgstr "" +#: pod/video/admin.py +msgid "Change video owner" +msgstr "" + #: pod/video/forms.py msgid "File field" msgstr "" @@ -7597,10 +7598,6 @@ msgstr "" msgid "Permanently delete your category?" msgstr "" -#: pod/video/templates/videos/change_video_owner.html -msgid "Change video owner" -msgstr "" - #: pod/video/templates/videos/change_video_owner.html msgid "Old owner" msgstr "" @@ -7647,10 +7644,8 @@ msgid "Choose an action" msgstr "" #: pod/video/templates/videos/dashboard.html -#, fuzzy -#| msgid "Title field" msgid "Edit a field" -msgstr "Titel veld" +msgstr "" #: pod/video/templates/videos/dashboard.html msgid "Cursus" @@ -8697,8 +8692,3 @@ msgstr "" #: pod/xapi/apps.py msgid "Esup-Pod xAPI" msgstr "" - -#, fuzzy -#~| msgid "Email sent?" -#~ msgid "Email addresse" -#~ msgstr "Email verzonden?" diff --git a/pod/locale/nl/LC_MESSAGES/djangojs.po b/pod/locale/nl/LC_MESSAGES/djangojs.po index 728800221b..44034858f7 100644 --- a/pod/locale/nl/LC_MESSAGES/djangojs.po +++ b/pod/locale/nl/LC_MESSAGES/djangojs.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Esup-Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-02-15 10:00+0000\n" +"POT-Creation-Date: 2024-02-15 17:43+0100\n" "PO-Revision-Date: 2023-02-08 15:22+0100\n" "Last-Translator: obado \n" "Language-Team: \n" diff --git a/pod/main/configuration.json b/pod/main/configuration.json index 2b759e6213..1752dcb985 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -899,10 +899,55 @@ }, "pod_version_end": "", "pod_version_init": "3.3.0" + }, + "USE_IMPORT_VIDEO_BBB_RECORDER": { + "default_value": false, + "description": { + "en": [ + "Using the bbb-recorder plugin for the video import module; ", + "useful for converting a BigBlueButton presentation into a video file." + ], + "fr": [ + "Utilisation du plugin bbb-recorder pour le module import-vidéo; ", + "utile pour convertir une présentation BigBlueButton en fichier vidéo." + ] + }, + "pod_version_end": "", + "pod_version_init": "3.5.1" + }, + "IMPORT_VIDEO_BBB_RECORDER_PLUGIN": { + "default_value": "/home/pod/bbb-recorder/", + "description": { + "en": [ + "bbb-recorder plugin directory (see https://github.com/jibon57/bbb-recorder documentation).", + "bbb-recorder must be installed in this directory, on all encoding servers.", + "bbb-recorder creates a Downloads directory, at the same level, which requires disk space." + ], + "fr": [ + "Répertoire du plugin bbb-recorder (voir la documentation https://github.com/jibon57/bbb-recorder).", + "bbb-recorder doit être installé dans ce répertoire, sur tous les serveurs d'encodage.", + "bbb-recorder crée un répertoire Downloads, au même niveau, qui nécessite de l'espace disque." + ] + }, + "pod_version_end": "", + "pod_version_init": "3.5.1" + }, + "IMPORT_VIDEO_BBB_RECORDER_PATH": { + "default_value": true, + "description": { + "en": [ + "Directory that will contain the video files generated by bbb-recorder." + ], + "fr": [ + "Répertoire qui contiendra les fichiers vidéo générés par bbb-recorder." + ] + }, + "pod_version_end": "", + "pod_version_init": "3.5.1" } }, "title": { - "en": "", + "en": "Video import application configuration", "fr": "Configuration application d'import vidéo" } }, diff --git a/pod/main/tasks.py b/pod/main/tasks.py index 4d3e6caf6a..580102c34a 100644 --- a/pod/main/tasks.py +++ b/pod/main/tasks.py @@ -65,3 +65,31 @@ def task_end_live_transcription(self, slug): if running_task: self.app.control.revoke(running_task.task_id, terminate=True) running_task.delete() + + +@app.task(bind=True) +def task_start_bbb_presentation_encode_and_upload_to_pod( + self, + record_id: int, + url: str, + extension: str +): + """Start BBB presentation encoding with Celery, then upload to Pod.""" + print("CELERY START BBB ENCODE PRESENTATION/UPLOAD RECORD ID %s" % record_id) + from pod.import_video.views import bbb_encode_presentation_and_upload_to_pod + + bbb_encode_presentation_and_upload_to_pod(record_id, url, extension) + + +@app.task(bind=True) +def task_start_bbb_presentation_encode_and_move_to_destination( + self, + filename: str, + url: str, + dest_file: str +): + """Start BBB presentation encoding with Celery, then move the video file.""" + print("CELERY START BBB ENCODE PRESENTATION/MOVE %s" % filename) + from pod.import_video.views import bbb_encode_presentation_and_move_to_destination + + bbb_encode_presentation_and_move_to_destination(filename, url, dest_file) diff --git a/pod/meeting/views.py b/pod/meeting/views.py index 954da9cae3..9809653dec 100644 --- a/pod/meeting/views.py +++ b/pod/meeting/views.py @@ -1247,7 +1247,7 @@ def upload_bbb_recording_to_pod(request, record_id, meeting_id): '%(url)s' ) % {"type": "Big Blue Button", "url": source_video_url} - save_video(request, dest_path, recording_title, description) + save_video(request.user, dest_path, recording_title, description) return True except Exception as exc: diff --git a/pod/video/management/commands/migrate_bbb_recordings.py b/pod/video/management/commands/migrate_bbb_recordings.py new file mode 100644 index 0000000000..eb153e547b --- /dev/null +++ b/pod/video/management/commands/migrate_bbb_recordings.py @@ -0,0 +1,876 @@ +""" +Useful script for migrating BBB records when changing BBB +infrastructure. Typically, when moving from a local architecture to the +French Ministry of Higher Education and Research (ESR) BBB architecture. + +More information on this module at: https://www.esup-portail.org/wiki/x/C4CFUQ + +Reminder of constraints : + - no use of Pod's old BBB module (this module will be phased out in the near future) + - problem with the BBB API: moderators are only available when the BBB session + session is in progress. After that, the information is no longer available in BBB. + This information is present in the BBB client, i.e. Pod or Moodle + (or Greenlight...). + - by default, it is possible to reconstruct a BBB record in the BBB infrastructure + (typically to obtain the recording in video format) only if the raw files + are still present. + By default, these raw files are deleted after 14 days. + +Principle : + - add recording management to the external video import module, + BBB, presentation type. This means using the Github project bbb-recorder; + in any case, there's no choice for this migration. + You need to install bbb-recorder on the encoding servers and set up + IMPORT_VIDEO_BBB_RECORDER_PLUGIN and IMPORT_VIDEO_BBB_RECORDER_PATH correctly. + This feature has been added in Esup-Pod 3.5.1. + + - create this migration script, which offers several possibilities: + + * 1° option, for those who have few recordings to recover + +This script will convert presentations from the old BBB architecture into video files +(as before, via the bbb-recorder plugin) and put these files in the directory +of a recorder used to claim recordings. +Of course, if there are already video presentations, the video file will be copied +directly. +Once all the videos have been encoded, the local BBB architecture can be stopped. +This is made possible by using the --use-manual-claim parameter and the +setup directly in this file. +Please note that, depending on your Pod architecture, encoding will be performed +either via Celery tasks or directly, one after the other. Don't hesitate to test +on a few recordings first and run this script in the background (use &). + + * 2nd option, for those who have a lot of recordings to retrieve + +The idea is to give users time to choose which records they wish to keep +(it's not possible or useful to convert everything). +To do this, you'll need to leave the old BBB/Scalelite server open at least +for a few months (just to access the records). +On the scripting side, you'll need access to the Moodle database to know +who did what. +So, for each BBB recording, the script would create a line in Pod's +"My external videos" module, of type BBB, for the moderators. +If these moderators have never logged into Pod, they will be created in Pod. +They can then import these recordings into Pod themselves. +Just in case, if records are not identifiable, they will be associated +to an administrator. In this way, for records from sources other than Pod or Moodle, +they will automatically be associated with an administrator (unless +this script is modified). +It is also planned (if access to the Moodle database is write-only, of course) to add +information directly to the BBB session in Moodle (intro field). +This is made possible by using the --use-import-video parameter, +the --use-database-moodle parameter and setup directly in this file. +This script has already been tested with Moodle 4. + + +This script also allows you to : + - simulate what will be done via the --dry parameter + - process only certain lines via the --min-value-record-process + and --max-value-record-process parameters. + +Examples and use cases : + * Use of record claim for all records, in simulation only: + python -W ignore manage.py migrate_bbb_recordings --use-manual-claim --dry + + * Use of record claim for only 2 records, in simulation only: + python -W ignore manage.py migrate_bbb_recordings --min-value-record-process=1 + --max-value-record-process=2 --use-manual-claim --dry & + + - Use of external video import module, with access to Moodle database for + all recordings, in simulation only: + * python -W ignore manage.py migrate_bbb_recordings --use-import-video + --use-database-moodle --dry + +Documentation for this system is available on the Esup-Pod Wiki: +https://www.esup-portail.org/wiki/x/C4CFUQ + +""" +import hashlib +import json +import os +import requests +import traceback +from datetime import datetime as dt +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.sites.shortcuts import get_current_site +from django.core.management.base import BaseCommand +from django.template.defaultfilters import slugify +from pod.import_video.utils import manage_download, parse_remote_file +from pod.import_video.utils import manage_recording_url, check_url_exists +from pod.import_video.models import ExternalRecording +from pod.import_video.views import start_bbb_encode_presentation_and_move_to_destination +from pod.meeting.models import Meeting +from pod.recorder.models import Recorder +from xml.dom import minidom + +# For PostgreSQL database # +# Don't forget to run the following command the 1st time +# pip install psycopg2-binary +import psycopg2 +import psycopg2.extras +from psycopg2 import sql + +# For MariaDB/MySQL database # +# Don't forget to run the following command the 1st time +# pip install mysql-connector-python +# import mysql.connector + + +# # Script config (TO EDIT) # # +# Old BigBlueButton config # +# Old BigBlueButton/Scalelite server URL +SCRIPT_BBB_SERVER_URL = 'https://bbb.univ.fr/' +# BigBlueButton key or Scalelite LOADBALANCER_SECRET +SCRIPT_BBB_SECRET_KEY = '' +# BBB or Scalelite version is greater than 2.3 +# Useful for presentation playback in 2.0 (for BBB <= 2.2) or 2.3 (for BBB >= 2.3) format +# Set to True by default +SCRIPT_PLAYBACK_URL_23 = True +# # + +# use-manual-claim # +# Recorder used to get BBB recordings +SCRIPT_RECORDER_ID = 1 +# # + +# use-import-video # +# Administrator to whom recordings will be added, +# whose moderators have not been identified +SCRIPT_ADMIN_ID = 1 +# # + +# use-database-moodle # +# Moodle database connection parameters +DB_PARAMS = { + 'host': 'bddmoodle.univ.fr', + 'database': 'moodle', + 'user': 'moodle', + 'password': '', + 'port': '', + 'connect_timeout': '10' +} +# Information message set in Moodle database, table mdl_bigbluebuttonbn, field intro +SCRIPT_INFORM = ( + "

" + "Suite au changement d'infrastructure BigBlueButton, les enregistrements BBB " + "réalisées avant le 01/06/2024 ne sont plus accessibles par défaut dans Moodle.
" + "Ces enregistrements seront disponibles du 01/06/2024 au 01/12/2024 sur Pod" + "(" + "pod.univ.fr), " + "via le module Importer une vidéo externe.
" + "Vous retrouverez dans ce module vos anciens enregistrements BBB, qu'il vous sera " + "possible de convertir en vidéo pour les rendre accessibles à vos usagers.
" + "Pour plus d'informations sur cette migration, n'hésitez pas à consulter " + "la page dédiée sur le site numerique.univ.fr" + ".
" + "Accéder au module d'import des vidéos dans Pod." + "

" +) +# # +# # # # + + +# # Config from settings-local.py (CUSTOM SETTINGS) # # +# Path used in the event of a claim +DEFAULT_RECORDER_PATH = getattr(settings, "DEFAULT_RECORDER_PATH", "/data/ftp-pod/ftp/") +# # # # + +# Global variable +number_records_to_encode = 0 + + +def connect_moodle_database(): + """Connect to the Moodle database and returns cursor.""" + try: + # For Postgre database + connection = psycopg2.connect(**DB_PARAMS) + cursor = connection.cursor(cursor_factory=psycopg2.extras.DictCursor) + # For MariaDB/MySQL database + # connection = mysql.connector.connect(**DB_PARAMS) + # cursor = connection.cursor() + return connection, cursor + + # For MariaDB/MySQL database + # except mysql.connector.Error as e: + # For Postgre database + except psycopg2.Error as e: + print("Error: Unable to connect to the Moodle database.") + print(e) + return None, None + + +def disconnect_moodle_database(connection, cursor): + """Disconnect to Moodle database.""" + try: + if cursor: + cursor.close() + if connection: + connection.close() + except Exception as e: + print("Error: Unable to disconnect to the Moodle database.") + print(e) + + +def process(options): + """Achieve the BBB recordings migration""" + + # Get the BBB recordings from BBB/Scalelite server API + recordings = get_bbb_recordings_by_xml() + + print("***Total number of records: %s***" % len(recordings)) + + number_record_processed = ( + options['max_value_record_process'] - options['min_value_record_process'] + 1 + ) + print("***Number of records to be processed: %s***" % number_record_processed) + + # Manage the recordings + i = 0 + for recording in recordings: + i += 1 + # Only recordings within the interval are taken into account. + if ( + i >= options['min_value_record_process'] + and i <= options['max_value_record_process'] + ): + # Get the recording + generic_recording = get_recording(recording) + if options['use_manual_claim']: + # #1 Use Manual claim + print( + "------------------------------\n" + "Use manual claim for recording #%s %s" % ( + str(i), + generic_recording.internal_meeting_id + ) + ) + # The claim requires the encoding of records in presentation. + process_recording_to_claim(options, generic_recording) + print("------------------------------") + elif options['use_import_video']: + # #2 Use import video + print( + "------------------------------\n" + "Use import video for recording #%s %s" % ( + str(i), + generic_recording.internal_meeting_id + ) + ) + process_recording_to_import_video(options, generic_recording) + print("------------------------------") + # Number of recordings to encode + print("***Number of records to encode in video: %s***" % number_records_to_encode) + + +def get_bbb_recordings_by_xml(): + """Get the BBB recordings from BBB/Scalelite server.""" + print("\n*** Get the BBB recordings from BBB/Scalelite server. ***") + recordings = [] + try: + # See https://docs.bigbluebutton.org/dev/api.html#usage + # for checksum and security + checksum = hashlib.sha1( + str("getRecordings" + SCRIPT_BBB_SECRET_KEY).encode("utf-8") + ).hexdigest() + # Request on BBB/Scalelite server (API) + # URL example: + # https://bbb.univ.fr/bigbluebutton/api/getRecordings?checksum=xxxx + urlToRequest = SCRIPT_BBB_SERVER_URL + urlToRequest += "bigbluebutton/api/getRecordings?checksum=" + checksum + addr = requests.get(urlToRequest) + print( + "Request on URL: " + urlToRequest + ", status: " + str(addr.status_code) + ) + # XML result to parse + xmldoc = minidom.parseString(addr.text) + returncode = xmldoc.getElementsByTagName("returncode")[0].firstChild.data + # Management of FAILED error (basically error in checksum) + if returncode == "FAILED": + err = "Return code = FAILED for: " + urlToRequest + err += " => : " + xmldoc.toxml() + "" + print(err) + # Actual recordings + recordings = xmldoc.getElementsByTagName("recording") + except Exception as e: + err = ( + "Problem to parse XML recordings on the BBB/Scalelite server " + "or save in Pod database: " + str(e) + ". " + traceback.format_exc() + ) + print(err) + return recordings + + +def get_recording(recording): # noqa: C901 + """Returns a BBB recording, using the Generic_recording class.""" + generic_recording = None + try: + # Get recording informations + internal_meeting_id = recording.getElementsByTagName("internalMeetingID")[ + 0 + ].firstChild.data + + meeting_id = recording.getElementsByTagName("meetingID")[ + 0 + ].firstChild.data + meeting_name = recording.getElementsByTagName("name")[ + 0 + ].firstChild.data + start_time = recording.getElementsByTagName("startTime")[ + 0 + ].firstChild.data + # Origin can be empty (Pod or other clients), Greenlight, Moodle... + origin = "" + if recording.getElementsByTagName("bbb-origin"): + origin = recording.getElementsByTagName("bbb-origin")[ + 0 + ].firstChild.data + + # Get recording URL that corresponds to the presentation URL + # Take only the "presentation" or "video" format + # Not other format like "screenshare" or "podcast" + presentation_url = "" + video_url = "" + # Check playback data + for playback in recording.getElementsByTagName("playback"): + # Depends on BBB parameters, we can have multiple format + for format in playback.getElementsByTagName("format"): + type = format.getElementsByTagName("type")[0].firstChild.data + # For bbb-recorder, we need URL of presentation format + if type == "presentation": + # Recording URL is the BBB presentation URL + presentation_url = format.getElementsByTagName("url")[ + 0 + ].firstChild.data + # Convert format 2.0 to 2.3 if necessary + presentation_url = convert_format( + presentation_url, + internal_meeting_id + ) + if type == "video": + # Recording URL is the BBB video URL + video_url = format.getElementsByTagName("url")[0].firstChild.data + + # Define the result with the Generic_recording class + generic_recording = Generic_recording( + internal_meeting_id, + meeting_id, + meeting_name, + start_time, + origin, + presentation_url, + video_url + ) + except Exception as e: + err = "Problem to get BBB recording: " + str(e) + ". " + traceback.format_exc() + print(err) + return generic_recording + + +def get_video_file_name(file_name, date, extension): + """Normalize a video file name""" + slug = slugify("%s %s" % (file_name[0:40], str(date)[0:10])) + return "%s.%s" % (slug, extension) + + +def download_bbb_video_file(source_url, dest_file): + """Download a BBB video playback""" + session = requests.Session() + # Download and parse the remote HTML file (BBB specific) + video_file_add = parse_remote_file(session, source_url) + # Verify that video exists + source_video_url = manage_recording_url(source_url, video_file_add) + if check_url_exists(source_video_url): + # Download the video file + source_video_url = manage_download( + session, + source_url, + video_file_add, + dest_file + ) + else: + print("Unable to download %s : video file doesn't exist." % source_url) + + +def process_recording_to_claim(options, generic_recording): + """Download video playback or encode presentation playback to recorder directory. + + Please note: the claim requires the encoding of records in presentation playback. + For a presentation playback, a process (asynchronous if CELERY_TO_ENCODE) is started. + Be careful : if not asynchronous, each encoding is made in a subprocess. + Must used in an asynchronous way. + """ + recorder = Recorder.objects.get(id=SCRIPT_RECORDER_ID) + + if generic_recording.video_url != "": + # Video playback + source_url = generic_recording.video_url + file_name = get_video_file_name( + generic_recording.meeting_name, + generic_recording.start_date, + "m4v" + ) + # Video file in the recorder directory + dest_file = os.path.join( + DEFAULT_RECORDER_PATH, + recorder.directory, + os.path.basename(file_name), + ) + print( + " - Recording %s, video playback %s: " + "download video file into %s." % + ( + generic_recording.internal_meeting_id, + source_url, + dest_file + ) + ) + if not options['dry']: + # Download the video file + download_bbb_video_file(source_url, dest_file) + else: + # Increment the number of records to be encoded + global number_records_to_encode + number_records_to_encode += 1 + + source_url = generic_recording.presentation_url + file_name = get_video_file_name( + generic_recording.meeting_name, + generic_recording.start_date, + "webm" + ) + # Video file generated in the recorder directory + dest_file = os.path.join( + DEFAULT_RECORDER_PATH, + recorder.directory, + os.path.basename(file_name), + ) + print( + " - Recording %s - presentation playback %s: " + "encode presentation into %s" % + ( + generic_recording.internal_meeting_id, + source_url, + dest_file + ) + ) + if not options['dry']: + # Send asynchronous/synchronous encode task (depends on CELERY_TO_ENCODE) + # to convert presentation in video and move it into the recorder directory + start_bbb_encode_presentation_and_move_to_destination( + file_name, + source_url, + dest_file + ) + + +def process_recording_to_import_video(options, generic_recording): + """Add the recording to the import video in Pod. + + Please note: the link must remain valid for several months, + until the user can import it into Pod. + """ + msg = "" + # List of owners (generic_user) + generic_owners = [] + # Default owner is the administrator (if no user found) + owner = User.objects.get(id=SCRIPT_ADMIN_ID) + # Default site + site = get_current_site(None) + # Search if this recording was made with Pod + meeting = get_created_in_pod(generic_recording) + if meeting: + # Recording made with Pod, we found the owner and the site + owner = meeting.owner + site = meeting.site + msg = "BBB session made with Pod." + else: + # Search if this recording was made with Moodle (if configured) + if options["use_database_moodle"]: + generic_owners = get_created_in_moodle(generic_recording) + msg = "BBB session made with Moodle." + if generic_owners: + msg += " Create user in Pod if necessary." + # Owners found in Moodle + for generic_owner in generic_owners: + # Create if necessary the user in Pod + pod_owner = get_or_create_user_pod(options, generic_owner) + # Create the external recording for an owner, if necessary + manage_external_recording(options, generic_recording, site, pod_owner, msg) + # Update information in Moodle (field intro) + set_information_in_moodle(options, generic_recording) + else: + # Only 1 owner (administrator at least if not found) + manage_external_recording(options, generic_recording, site, owner, msg) + + +def get_created_in_pod(generic_recording): + """Allow to know if this recording was made with Pod. + + In such a case, we know the meeting (owner information).""" + # Check if the recording was made by Pod client + meeting = Meeting.objects.filter( + meeting_id=generic_recording.meeting_id + ).first() + return meeting + + +def get_or_create_user_pod(options, generic_owner): + """Returns the Pod user corresponding to the generic owner. + + If necessary, create this user in Pod.""" + # Search for user in Pod database + user = User.objects.filter(username=generic_owner.username).first() + if user: + return user + else: + # Create user in Pod database + # Owner are necessary staff; + # if not, this will be changed at the user 1st connection. + if not options['dry']: + # Create + user = User.objects.create( + username=generic_owner.username, + first_name=generic_owner.firstname, + last_name=generic_owner.lastname, + email=generic_owner.email, + is_staff=True, + is_active=True + ) + return user + else: + return generic_owner + + +def manage_external_recording(options, generic_recording, site, owner, msg): + """Print and create an external recording for a BBB recording, if necessary.""" + print( + " - Recording %s, playback %s, owner %s: " + "create an external recording if necessary. %s" % + ( + generic_recording.internal_meeting_id, + generic_recording.source_url, + owner, + msg + ) + ) + if not options['dry']: + # Create the external recording for the owner + create_external_recording(generic_recording, site, owner) + + +def create_external_recording(generic_recording, site, owner): + """Create an external recording for a BBB recording, if necessary.""" + # Check if external recording already exists for this owner + external_recording = ExternalRecording.objects.filter( + source_url=generic_recording.source_url, + owner=owner + ).first() + if not external_recording: + # We need to create a new external recording + ExternalRecording.objects.create( + name=generic_recording.meeting_name, + start_at=generic_recording.start_date, + type="bigbluebutton", + source_url=generic_recording.source_url, + site=site, + owner=owner + ) + + +def get_created_in_moodle(generic_recording): # noqa: C901 + """Allow to know if this recording was made with Moodle. + + In such a case, we know the list of owners.""" + # Default value + owners_found = [] + try: + participants = "" + + connection, cursor = connect_moodle_database() + with cursor as c: + select_query = sql.SQL( + "SELECT b.id, b.intro, b.course, b.participants FROM " + "public.mdl_bigbluebuttonbn_recordings r, public.mdl_bigbluebuttonbn b " + "WHERE r.bigbluebuttonbnid = b.id " + "AND r.recordingid = '%s' " + "AND r.status = 2" % (generic_recording.internal_meeting_id) + ).format(sql.Identifier('type')) + c.execute(select_query) + results = c.fetchall() + for res in results: + # course_id = res["course"] + # Format for participants: + # '[{"selectiontype":"all","selectionid":"all","role":"viewer"}, + # {"selectiontype":"user","selectionid":"83","role":"moderator"}]' + participants = res["participants"] + if participants != "": + # Parse participants as a JSON string + parsed_data = json.loads(participants) + for item in parsed_data: + # Search for moderators + if ( + item["selectiontype"] == "user" + and item["role"] == "moderator" + ): + user_id_moodle = item["selectionid"] + user_moodle = get_moodle_user(user_id_moodle) + if user_moodle: + print(" - Moderator found in Moodle: %s %s" % ( + user_moodle.username, + user_moodle.email + )) + owners_found.append(user_moodle) + + except Exception as e: + err = ( + "Problem to find moderators for BBB recording in Moodle" + ": " + str(e) + ". " + traceback.format_exc() + ) + print(err) + return owners_found + + +def set_information_in_moodle(options, generic_recording): + """Set information about this migration in Moodle (if possible). + + Update the field : public.mdl_bigbluebuttonbn.intro in Moodle + to store information, if possible (database user in read/write, permissions...). + Use SCRIPT_INFORM. + """ + try: + connection, cursor = connect_moodle_database() + with cursor as c: + # Request for Moodle v4 + select_query = sql.SQL( + "SELECT b.id, b.intro, b.course, b.participants FROM " + "public.mdl_bigbluebuttonbn_recordings r, public.mdl_bigbluebuttonbn b " + "WHERE r.bigbluebuttonbnid = b.id " + "AND r.recordingid = '%s' " + "AND r.status = 2" % (generic_recording.internal_meeting_id) + ).format(sql.Identifier('type')) + c.execute(select_query) + results = c.fetchall() + for res in results: + id = res["id"] + intro = res["intro"] + # course_id = res["course"] + print(" - Set information in Moodle.") + # If SCRIPT_INFORM wasn't already added + if not options['dry'] and intro.find("Pod") == -1: + # Update + update_query = sql.SQL( + "UPDATE public.mdl_bigbluebuttonbn SET {} = %s WHERE id = %s" + ).format(sql.Identifier('intro')) + # New value for the intro column + new_intro = "%s
%s" % (intro, SCRIPT_INFORM) + # Execute the UPDATE query + cursor.execute(update_query, (new_intro, id)) + # Commit the transaction + connection.commit() + + except Exception as e: + # Non-blocking error + # Typically if user not allowed to write in Moodle database or permission not set + err = ( + "Not allow to set information in Moodle" + ": " + str(e) + ". " + traceback.format_exc() + ) + print(err) + + +def get_moodle_user(user_id): + """Returns a generic user by user id in Moodle database.""" + dict_user = [] + generic_user = None + connection, cursor = connect_moodle_database() + with cursor as c: + # Most important field: username + select_query = sql.SQL( + "SELECT id, username, firstname, lastname, email FROM public.mdl_user " + "WHERE id = '%s' " % (user_id) + ).format(sql.Identifier('type')) + c.execute(select_query) + dict_user = c.fetchone() + generic_user = Generic_user( + dict_user["id"], + dict_user["username"], + dict_user["firstname"], + dict_user["lastname"], + dict_user["email"] + ) + + return generic_user + + +def convert_format(source_url, internal_meeting_id): + """Convert presentation playback URL if necessary (see SCRIPT_PLAYBACK_URL_23).""" + try: + # Conversion - if necessary - from + # https://xxx/playback/presentation/2.0/playback.html?meetingId=ID + # to https://xxx/playback/presentation/2.3/ID?meetingId=ID + if SCRIPT_PLAYBACK_URL_23 and source_url.find("/2.0/") >= 0: + source_url = source_url.replace( + "/2.0/playback.html", "/2.3/" + internal_meeting_id + ) + except Exception as e: + err = "Problem to convert format: " + str(e) + ". " + traceback.format_exc() + print(err) + + return source_url + + +def check_system(options): # noqa: C901 + """Check the system (configuration, recorder, access rights). Blocking function.""" + error = False + if options['use_manual_claim']: + # A recorder is mandatory in the event of a claim + recorder = Recorder.objects.filter(id=SCRIPT_RECORDER_ID).first() + if not recorder: + error = True + print( + "ERROR : No recorder found with id %s. " + "Please create one." % SCRIPT_RECORDER_ID + ) + else: + claim_path = os.path.join(DEFAULT_RECORDER_PATH, recorder.directory) + # Check directory exist + if not os.path.exists(claim_path): + error = True + print( + "ERROR : Directory %s doesn't exist. " + "Please configure one." % claim_path + ) + if options['use_import_video']: + # Administrator to whom recordings will be added, + # whose moderators have not been identified + administrator = User.objects.filter(id=SCRIPT_ADMIN_ID).first() + if not administrator: + error = True + print( + "ERROR : No administrator found with id %s. " + "Please create one." % SCRIPT_ADMIN_ID + ) + if options['use_database_moodle']: + # Check connection to Moodle database + connection, cursor = connect_moodle_database() + if not cursor: + error = True + print( + "ERROR : Unable to connect to Moodle database. Please configure " + "DB_PARAMS in this file, check firewall rules and permissions." + ) + else: + disconnect_moodle_database(connection, cursor) + + if error: + exit() + + +class Generic_user: + """Class for a generic user.""" + def __init__(self, user_id, username, firstname, lastname, email): + """Initialize.""" + self.id = user_id + self.username = username + self.firstname = firstname + self.lastname = lastname + self.email = email + + def __str__(self): + """Display a generic user object as string.""" + if self.id: + return "%s %s (%s)" % (self.firstname, self.lastname, self.username) + else: + return "None" + + +class Generic_recording: + """Class for a generic recording.""" + def __init__( + self, + internal_meeting_id, + meeting_id, + meeting_name, + start_time, + origin, + presentation_url, + video_url + ): + """Initialize.""" + self.internal_meeting_id = internal_meeting_id + self.meeting_id = meeting_id + self.meeting_name = meeting_name + self.start_time = start_time + self.origin = origin + self.presentation_url = presentation_url + self.video_url = video_url + # Generated formatted date + self.start_date = dt.fromtimestamp(float(start_time) / 1000) + # Generated source URL : video playback if possible + self.source_url = self.video_url + if self.source_url == "": + # Else presentation playback + self.source_url = self.presentation_url + + +class Command(BaseCommand): + """Migrate BBB recordings into the Pod database.""" + + help = "Migrate BigBlueButton recordings" + + def add_arguments(self, parser): + """Allow arguments to be used with the command.""" + parser.add_argument( + "--use-manual-claim", + action="store_true", + default=False, + help="Use manual claim (default=False)?" + ) + parser.add_argument( + "--use-import-video", + action="store_true", + default=False, + help="Use import video module to get recordings (default=False)?" + ) + parser.add_argument( + "--use-database-moodle", + action="store_true", + default=False, + help=( + "Use Moodle database to search for moderators (default=False)? " + "Only useful when --use-import-video was set to True." + ) + ) + parser.add_argument( + "--min-value-record-process", + type=int, + default=1, + help="Minimum value of records to process (default=1)." + ) + parser.add_argument( + "--max-value-record-process", + type=int, + default=10000, + help="Maximum value of records to process (default=10000)." + ) + parser.add_argument( + "--dry", + help="Simulates what will be achieved (default=False).", + action="store_true", + default=False, + ) + + def handle(self, *args, **options): + """Handle the BBB migration command call.""" + + if options["dry"]: + print("Simulation mode ('dry'). Nothing will be achieved.") + + # Check configuration, recoder, rights... + check_system(options) + + # Main function + process(options)