diff --git a/pod/live/apps.py b/pod/live/apps.py index 3374460715..14fe4e74dc 100644 --- a/pod/live/apps.py +++ b/pod/live/apps.py @@ -11,6 +11,7 @@ def set_default_site(sender, **kwargs): + """Set a default Site for Building not having one.""" from pod.live.models import Building from django.contrib.sites.models import Site @@ -20,40 +21,66 @@ def set_default_site(sender, **kwargs): build.save() +def add_default_opencast(sender, **kwargs): + """Add the key 'use_opencast' with value False, in the json conf of the Broadcaster if not present.""" + from pod.live.models import Broadcaster + + brds = Broadcaster.objects.filter( + piloting_implementation="SMP", piloting_conf__isnull=False + ).all() + for brd in brds: + conf = brd.piloting_conf + if "use_opencast" not in conf: + brd.piloting_conf = conf.replace("}", ',"use_opencast":"false"}') + brd.save() + + +def save_previous_data(sender, **kwargs): + """Save all live events if model has date and time in different fields.""" + results = [] + try: + with connection.cursor() as c: + c.execute("SELECT id, start_date, start_time, end_time FROM live_event") + results = c.fetchall() + for res in results: + __EVENT_DATA__["%s" % res[0]] = [res[1], res[2], res[3]] + except Exception: # OperationalError or MySQLdb.ProgrammingError + pass # print('OperationalError: ', oe) + + +def send_previous_data(sender, **kwargs): + """Set start and end dates with date + time to all saved events.""" + from .models import Event + + for data_id in __EVENT_DATA__: + try: + evt = Event.objects.get(id=data_id) + d_start = datetime.combine( + __EVENT_DATA__[data_id][0], __EVENT_DATA__[data_id][1] + ) + d_start = timezone.make_aware(d_start) + evt.start_date = d_start + d_fin = datetime.combine( + __EVENT_DATA__[data_id][0], __EVENT_DATA__[data_id][2] + ) + d_fin = timezone.make_aware(d_fin) + evt.end_date = d_fin + evt.save() + except Event.DoesNotExist: + print("Event not found: %s" % data_id) + + class LiveConfig(AppConfig): + """Config file for live app.""" + name = "pod.live" default_auto_field = "django.db.models.BigAutoField" # event_data = {} verbose_name = _("Lives") def ready(self): + """Init tasks.""" + pre_migrate.connect(save_previous_data, sender=self) + post_migrate.connect(add_default_opencast, sender=self) post_migrate.connect(set_default_site, sender=self) - pre_migrate.connect(self.save_previous_data, sender=self) - post_migrate.connect(self.send_previous_data, sender=self) - - def save_previous_data(self, sender, **kwargs): - results = [] - try: - with connection.cursor() as c: - c.execute("SELECT id, start_date, start_time, end_time FROM live_event") - results = c.fetchall() - for res in results: - __EVENT_DATA__["%s" % res[0]] = [res[1], res[2], res[3]] - except Exception: # OperationalError or MySQLdb.ProgrammingError - pass # print('OperationalError: ', oe) - - def send_previous_data(self, sender, **kwargs): - from .models import Event - - for id in __EVENT_DATA__: - try: - evt = Event.objects.get(id=id) - d_start = datetime.combine(__EVENT_DATA__[id][0], __EVENT_DATA__[id][1]) - d_start = timezone.make_aware(d_start) - evt.start_date = d_start - d_fin = datetime.combine(__EVENT_DATA__[id][0], __EVENT_DATA__[id][2]) - d_fin = timezone.make_aware(d_fin) - evt.end_date = d_fin - evt.save() - except Event.DoesNotExist: - print("Event not found: %s" % id) + post_migrate.connect(send_previous_data, sender=self) diff --git a/pod/live/models.py b/pod/live/models.py index f4fd45df4b..a3d4c1d7e6 100644 --- a/pod/live/models.py +++ b/pod/live/models.py @@ -188,15 +188,15 @@ class Broadcaster(models.Model): piloting_implementation = models.CharField( max_length=100, - blank=True, null=True, + default="", verbose_name=_("Piloting implementation"), help_text=_("Select the piloting implementation for to this broadcaster."), ) piloting_conf = models.TextField( - null=True, blank=True, + default="", verbose_name=_("Piloting configuration parameters"), help_text=_("Add piloting configuration parameters in Json format."), ) diff --git a/pod/live/pilotingInterface.py b/pod/live/pilotingInterface.py index feb1919223..a37676402d 100644 --- a/pod/live/pilotingInterface.py +++ b/pod/live/pilotingInterface.py @@ -7,7 +7,7 @@ import re from abc import ABC as __ABC__, abstractmethod from datetime import timedelta -from typing import Optional, List +from typing import Optional import paramiko import requests @@ -19,22 +19,31 @@ DEFAULT_EVENT_PATH = getattr(settings, "DEFAULT_EVENT_PATH", "") +CREATE_VIDEO_FROM_FTP = "fetch file from remote using ftp" +CREATE_VIDEO_FROM_FS = "file is in Pod file system" +CREATE_VIDEO_OPENCAST = "file is automatically sent to the recorder module" + logger = logging.getLogger(__name__) __EXISTING_BROADCASTER_IMPLEMENTATIONS__ = ["Wowza", "SMP"] __MANDATORY_PARAMETERS__ = { "Wowza": {"server_url", "application", "livestream"}, - "SMP": { + "SMP": [ "server_url", "sftp_port", "user", "password", - "record_dir_path", + "use_opencast", "rtmp_streamer_id", - }, + "record_dir_path", + ], } +__OPTIONAL_IF_OPENCAST__ = [ + "record_dir_path", +] + class PilotingInterface(__ABC__): """Class to be implemented for any device (with an Api) we want to control in the event's page.""" @@ -51,8 +60,8 @@ def __init__(self, broadcaster: Broadcaster): raise NotImplementedError @abstractmethod - def copy_file_needed(self) -> bool: - """If the video file needs to be copied from a remote server.""" + def video_creation_method(self): + """The method used to create the video.""" raise NotImplementedError @abstractmethod @@ -84,7 +93,7 @@ def is_recording(self, with_file_check=False) -> bool: raise NotImplementedError @abstractmethod - def start_recording(self, event_id, login=None) -> bool: + def start_recording(self, event_id) -> bool: """Start the recording.""" raise NotImplementedError @@ -135,16 +144,25 @@ def ajax_get_mandatory_parameters(request): impl_name = request.GET.get("impl_name", None) params = get_mandatory_parameters(impl_name) params_json = {} - for value in params: - params_json[value] = "..." + for key in params: + if key in __OPTIONAL_IF_OPENCAST__: + params_json[key] = "(optional if 'use_opencast':'true') ..." + else: + params_json[key] = "..." return JsonResponse(data=params_json) return HttpResponseNotAllowed(["GET"]) -def get_mandatory_parameters(impl_name="") -> List[str]: - """Return the mandatory parameters of the implementation.""" +def get_mandatory_parameters(impl_name=""): + """Return the mandatory parameters of the implementation. + Args: + impl_name (str): The name of the implementation to retrieve mandatory parameters for. + + Returns: + dict[str, str]: A dict with the mandatory parameters for the specified implementation. + """ if impl_name in __MANDATORY_PARAMETERS__: return __MANDATORY_PARAMETERS__[impl_name] if impl_name.lower() in __MANDATORY_PARAMETERS__: @@ -153,7 +171,19 @@ def get_mandatory_parameters(impl_name="") -> List[str]: return __MANDATORY_PARAMETERS__[impl_name.title()] if impl_name.upper() in __MANDATORY_PARAMETERS__: return __MANDATORY_PARAMETERS__[impl_name.upper()] - return [""] + return {} + + +def get_missing_params(config: dict, parameters: dict): + """Compare parameters with configuration and returns missing ones.""" + missing = "" + use_opencast = config.get("use_opencast", "").lower() == "true" + for parameter in parameters: + if parameter not in config.keys(): + if use_opencast and parameter in __OPTIONAL_IF_OPENCAST__: + continue + missing += "'" + parameter + "':'...'," + return missing def validate_json_implementation(broadcaster: Broadcaster) -> bool: @@ -164,8 +194,9 @@ def validate_json_implementation(broadcaster: Broadcaster) -> bool: "'piloting_conf' value is not set for '" + broadcaster.name + "' broadcaster." ) return False + try: - decoded = json.loads(conf) + config = json.loads(conf) except Exception as e: logger.error( "'piloting_conf' has not a valid Json format for '" @@ -177,16 +208,14 @@ def validate_json_implementation(broadcaster: Broadcaster) -> bool: parameters = get_mandatory_parameters(broadcaster.piloting_implementation) - if not parameters <= decoded.keys(): - mandatory = "" - for value in parameters: - mandatory += "'" + value + "':'...'," + missing = get_missing_params(config, parameters) + + if missing: logger.error( - "'piloting_conf' format value for '" + "'piloting_conf' value for '" + broadcaster.name - + "' broadcaster must be like: " - + "{" - + mandatory[:-1] + + "' must define: {" + + missing + "}" ) return False @@ -277,9 +306,9 @@ def __init__(self, broadcaster: Broadcaster): application=conf["application"], ) - def copy_file_needed(self) -> bool: - """Implement copy_file_needed from PilotingInterface.""" - return False + def video_creation_method(self): + """Implement video_creation_method from PilotingInterface.""" + return CREATE_VIDEO_FROM_FS def can_split(self) -> bool: """Implement can_split from PilotingInterface.""" @@ -338,7 +367,7 @@ def is_recording(self, with_file_check=False) -> bool: else: return True - def start_recording(self, event_id, login=None) -> bool: + def start_recording(self, event_id) -> bool: """Implement start_recording from PilotingInterface.""" logger.debug("Wowza - Start record") json_conf = self.broadcaster.piloting_conf @@ -347,8 +376,6 @@ def start_recording(self, event_id, login=None) -> bool: self.url + "/instances/_definst_/streamrecorders/" + conf["livestream"] ) filename = str(event_id) + "_" + self.broadcaster.slug - if login is not None: - filename = login + "_" + filename data = { "instanceName": "", "fileVersionDelegateName": "", @@ -493,6 +520,7 @@ class Smp(PilotingInterface): def __init__(self, broadcaster: Broadcaster): self.broadcaster = broadcaster self.url = None + self.use_opencast = False if self.check_piloting_conf(): conf = json.loads(self.broadcaster.piloting_conf) url = "{server_url}/api/swis/resources" @@ -500,10 +528,13 @@ def __init__(self, broadcaster: Broadcaster): server_url=conf["server_url"], # smp_version=conf["smp_version"], ) + self.use_opencast = conf["use_opencast"].lower() == "true" - def copy_file_needed(self) -> bool: - """Implement copy_file_needed from PilotingInterface.""" - return True + def video_creation_method(self): + """Implement video_creation_method from PilotingInterface.""" + if self.use_opencast: + return CREATE_VIDEO_OPENCAST + return CREATE_VIDEO_FROM_FTP def can_split(self) -> bool: """Implement can_split from PilotingInterface.""" @@ -543,7 +574,7 @@ def is_recording(self, with_file_check=False) -> bool: return self.verify_smp_response(response, "result", "recording") - def start_recording(self, event_id, login=None) -> bool: + def start_recording(self, event_id) -> bool: """Implement start_recording from PilotingInterface.""" logger.debug("Smp - Start record") json_conf = self.broadcaster.piloting_conf @@ -552,7 +583,6 @@ def start_recording(self, event_id, login=None) -> bool: event = Event.objects.filter(id=event_id).first() filename = event.slug if event else str(event_id) + "_" + self.broadcaster.slug - login = login if login else "unknown" body = json.dumps( [ {"uri": "/record/1/root_dir_fs", "value": "internal"}, @@ -562,9 +592,11 @@ def start_recording(self, event_id, login=None) -> bool: "recording": "record", "location": "internal", "metadata": { + "course_id": event_id, + "creator": event.owner.username, "title": filename, - "creator": login, - "description": "launch from Pod", + "description": "", + "relation": "", }, }, }, @@ -637,6 +669,10 @@ def copy_file_to_pod_dir(self, filename): """Implement copy_file_to_pod_dir from PilotingInterface.""" logger.debug("Smp - Copy file to Pod dir") + # not needed if file is sent to the Recorder + if self.use_opencast: + return True + json_conf = self.broadcaster.piloting_conf conf = json.loads(json_conf) diff --git a/pod/live/tests/test_views.py b/pod/live/tests/test_views.py index c96be30b9c..d79d19713a 100644 --- a/pod/live/tests/test_views.py +++ b/pod/live/tests/test_views.py @@ -85,7 +85,8 @@ def setUp(self): '"user": "username", ' '"password": "mdp", ' '"rtmp_streamer_id": "1", ' - '"record_dir_path": "/recording"}', + '"record_dir_path": "/recording", ' + '"use_opencast": "true"}', ) Video.objects.create( title="VideoOnHold", @@ -1983,6 +1984,7 @@ def test_methodes_start_and_stop_stream(self): """Test test_methode_start_stream.""" from pod.live.views import start_stream, stop_stream + # no impl broad_no_impl = Broadcaster.objects.get(id=1) response = start_stream(broad_no_impl) self.assertFalse(response) @@ -1993,6 +1995,7 @@ def test_methodes_start_and_stop_stream(self): self.assertFalse(response) print(" ---> test methode_stop_stream no_impl: OK!") + # cannot manage stream broad_no_manage = Broadcaster.objects.get(id=2) response = start_stream(broad_no_manage) self.assertFalse(response) @@ -2003,17 +2006,18 @@ def test_methodes_start_and_stop_stream(self): self.assertFalse(response) print(" ---> test methode_stop_stream cannot manage: OK!") + # can manage stream + broad_manage = Broadcaster.objects.get(id=3) + @all_requests def rtmp_response_no_valid_record(url, request): return httmock.response(200, json.dumps(({"key": "value"},))) with HTTMock(rtmp_response_no_valid_record): - broad_manage = Broadcaster.objects.get(id=3) response = start_stream(broad_manage) self.assertFalse(response) print(" ---> test methode_start_stream: OK!") - broad_manage = Broadcaster.objects.get(id=3) response = stop_stream(broad_manage) self.assertFalse(response) print(" ---> test methode_stop_stream: OK!") @@ -2037,11 +2041,10 @@ def rtmp_response_data(url, request): return httmock.response(200, rtmp_response_body(1)) with HTTMock(rtmp_response_data): - broad_manage = Broadcaster.objects.get(id=3) response = start_stream(broad_manage) self.assertTrue(response) print(" ---> test methode_start_stream already started: OK!") - broad_manage = Broadcaster.objects.get(id=3) + response = stop_stream(broad_manage) self.assertFalse(response) print(" ---> test methode_stop_stream stops: OK!") @@ -2051,11 +2054,10 @@ def rtmp_response_data(url, request): return httmock.response(200, rtmp_response_body(0)) with HTTMock(rtmp_response_data): - broad_manage = Broadcaster.objects.get(id=3) response = start_stream(broad_manage) self.assertFalse(response) print(" ---> test methode_start_stream starts: OK!") - broad_manage = Broadcaster.objects.get(id=3) + response = stop_stream(broad_manage) self.assertTrue(response) print(" ---> test methode_stop_stream already stopped: OK!") diff --git a/pod/live/views.py b/pod/live/views.py index b1818de3c0..862ab0cffd 100644 --- a/pod/live/views.py +++ b/pod/live/views.py @@ -40,7 +40,12 @@ Event, get_available_broadcasters_of_building, ) -from .pilotingInterface import get_piloting_implementation +from .pilotingInterface import ( + get_piloting_implementation, + CREATE_VIDEO_FROM_FTP, + CREATE_VIDEO_FROM_FS, + CREATE_VIDEO_OPENCAST, +) from .utils import ( send_email_confirmation, get_event_id_and_broadcaster_id, @@ -793,7 +798,7 @@ def ajax_event_splitrecord(request): def event_splitrecord(event_id, broadcaster_id): """Call the split method of the broadcaster's implementation. - and converts the file to a Pod video (linked to the event). + And converts the file to a Pod video (linked to the event). Returns: a JsonResponse with success state and the error (in case of failure). """ @@ -830,7 +835,7 @@ def ajax_event_stoprecord(request): def event_stoprecord(event_id, broadcaster_id): """Call the stop method of the broadcaster's implementation. - and converts the file to a Pod video (linked to the event). + And converts the file to a Pod video (linked to the event). Returns: a JsonResponse with success state and the error (in case of failure). """ @@ -1156,8 +1161,12 @@ def transform_to_video(broadcaster, event_id, current_record_info): if not impl_class: return JsonResponse({"success": False, "error": "implementation error"}) - if impl_class.copy_file_needed(): - # Copie + transform faite dans un thread + vcm = impl_class.video_creation_method() + + if vcm == CREATE_VIDEO_OPENCAST: + return JsonResponse({"success": True, "error": ""}) + elif vcm == CREATE_VIDEO_FROM_FTP: + # copy_and_transform dans un thread t = threading.Thread( target=copy_and_transform, args=[impl_class, event_id, current_record_info], @@ -1165,17 +1174,21 @@ def transform_to_video(broadcaster, event_id, current_record_info): ) t.start() return JsonResponse({"success": True, "error": ""}) + elif vcm == CREATE_VIDEO_FROM_FS: + return create_video( + event_id, + current_record_info.get("currentFile", None), + current_record_info.get("segmentNumber", None), + ) - return create_video( - event_id, - current_record_info.get("currentFile", None), - current_record_info.get("segmentNumber", None), + return JsonResponse( + {"success": False, "error": "file_location() is not well-defined"} ) def copy_and_transform(impl_class, event_id, current_record_info): """ - Copy the file from remote to Pod and creates the video. + Copy the file from remote to Pod and create the video. Args: impl_class (PilotingInterface): the piloting interface of the broadcaster diff --git a/pod/recorder/admin.py b/pod/recorder/admin.py index ece1f8c904..d5633369c2 100644 --- a/pod/recorder/admin.py +++ b/pod/recorder/admin.py @@ -138,6 +138,7 @@ def save_model(self, request, obj, form, change): "name", "Description", "address_ip", + "credentials_login", "user", "type", "recording_type", diff --git a/pod/recorder/models.py b/pod/recorder/models.py index 3d5eeb5ba1..b1dd1f2f33 100644 --- a/pod/recorder/models.py +++ b/pod/recorder/models.py @@ -96,8 +96,23 @@ class Recorder(models.Model): salt = models.CharField( _("salt"), max_length=50, blank=True, help_text=_("Recorder salt.") ) - - # Recording type (video, AUdioVideoCasst, etc) + # Login for digest connexion + credentials_login = models.CharField( + _("credentials_login"), + blank=True, + default="", + max_length=255, + help_text=_("Recorder credentials login."), + ) + # Password for digest connexion + credentials_password = models.CharField( + _("credentials_password"), + blank=True, + default="", + max_length=255, + help_text=_("Recorder credentials password."), + ) + # Recording type (video, AudioVideoCast, etc,) recording_type = models.CharField( _("Recording Type"), max_length=50, @@ -118,7 +133,7 @@ class Recorder(models.Model): null=True, blank=True, ) - # Additionnal additional_users + # Additional users additional_users = models.ManyToManyField( User, blank=True, @@ -245,6 +260,22 @@ def ipunder(self): def save(self, *args, **kwargs): super(Recorder, self).save(*args, **kwargs) + def clean(self): + """Custom model validation.""" + cred_msg = _("All credentials must be set.") + cred_error = { + "credentials_login": cred_msg, + "credentials_password": cred_msg, + } + + # Ensure all credentials are set + if (not self.credentials_login and self.credentials_password) or ( + self.credentials_login and not self.credentials_password + ): + if not self.salt: + cred_error["salt"] = cred_msg + raise ValidationError(cred_error) + class Meta: verbose_name = _("Recorder") verbose_name_plural = _("Recorders") diff --git a/pod/recorder/plugins/type_studio.py b/pod/recorder/plugins/type_studio.py index b0e1d4b927..127f13aa59 100644 --- a/pod/recorder/plugins/type_studio.py +++ b/pod/recorder/plugins/type_studio.py @@ -1,22 +1,26 @@ # type_studio.py -import threading -import logging import datetime +import logging import os +import threading from xml.dom import minidom from django.conf import settings +from django.contrib.auth.models import User +from django.template.defaultfilters import slugify -from ..utils import add_comment, studio_clean_old_entries from pod.video.models import Video, get_storage_path_video from pod.video_encode_transcript import encode -from django.template.defaultfilters import slugify +from ..utils import add_comment, studio_clean_old_entries +from ...live.models import Event +from ...settings import BASE_DIR DEFAULT_RECORDER_TYPE_ID = getattr(settings, "DEFAULT_RECORDER_TYPE_ID", 1) ENCODE_VIDEO = getattr(settings, "ENCODE_VIDEO", "start_encode") ENCODE_STUDIO = getattr(settings, "ENCODE_STUDIO", "start_encode_studio") MEDIA_URL = getattr(settings, "MEDIA_URL", "/media/") +MEDIA_ROOT = getattr(settings, "MEDIA_ROOT", os.path.join(BASE_DIR, "media")) OPENCAST_FILES_DIR = getattr(settings, "OPENCAST_FILES_DIR", "opencast-files") # Possible value are "mid", "piph" or "pipb" OPENCAST_DEFAULT_PRESENTER = getattr(settings, "OPENCAST_DEFAULT_PRESENTER", "mid") @@ -26,9 +30,7 @@ def process(recording): log.info("START PROCESS OF RECORDING %s" % recording) - t = threading.Thread(target=encode_recording, args=[recording]) - t.setDaemon(True) - t.start() + threading.Thread(target=encode_recording, args=[recording], daemon=True).start() def save_basic_video(recording, video_src): @@ -99,7 +101,7 @@ def save_basic_video(recording, video_src): def generate_intermediate_video(recording, videos, clip_begin, clip_end, presenter): - # Video file output: at the same directory than the XML file + # Video file output: in the same directory as the XML file # And with the same name .mp4 video_output = recording.source_file.replace(".xml", ".mp4") subtime = get_subtime(clip_begin, clip_end) @@ -123,46 +125,27 @@ def encode_recording_id(recording_id): """ -# flake ignore complexity with noqa: C901 def encode_recording(recording): recording.comment = "" recording.save() add_comment(recording.id, "Start at %s\n--\n" % datetime.datetime.now()) try: - xmldoc = minidom.parse(recording.source_file) + xml_doc = minidom.parse(recording.source_file) except KeyError as e: add_comment(recording.id, "Error: %s" % e) return -1 - videos = getElementsByName(xmldoc, "track") - catalogs = getElementsByName(xmldoc, "catalog") - title = "" - clip_begin = None - clip_end = None - att_presenter = getAttributeByName(xmldoc, "mediapackage", "presenter") + videos = getElementsByName(xml_doc, "track") + catalogs = getElementsByName(xml_doc, "catalog") + att_presenter = getAttributeByName(xml_doc, "mediapackage", "presenter") presenter = ( att_presenter if (att_presenter in ["mid", "piph", "pipb"]) else OPENCAST_DEFAULT_PRESENTER ) - for catalog in catalogs: - xmldoc = minidom.parse(catalog.get("src")) - if catalog.get("type") == "dublincore/episode": - title = getElementValueByName(xmldoc, "dcterms:title") - change_title(recording, title) - if catalog.get("type") == "smil/cutting": - beginDefault = getAttributeByName(xmldoc, "video", "clipBegin") - endDefault = getAttributeByName(xmldoc, "video", "clipEnd") - clip_begin = ( - str(round(float(beginDefault.replace("s", "")), 2)) - if (beginDefault) - else None - ) - clip_end = ( - str(round(float(endDefault.replace("s", "")), 2)) - if (endDefault) - else None - ) + + clip_begin, clip_end, event_id = extract_infos_from_catalog(catalogs, recording) + if clip_begin or clip_end or len(videos) > 1: msg = "*** generate_intermediate_video (%s) %s ***" % ( videos[0].get("type"), @@ -176,51 +159,198 @@ def encode_recording(recording): videos[0].get("src"), ) add_comment(recording.id, msg) + + # create Video using Recording properties video = save_basic_video( recording, - os.path.join(settings.MEDIA_ROOT, OPENCAST_FILES_DIR, videos[0].get("src")), + os.path.join(MEDIA_ROOT, OPENCAST_FILES_DIR, videos[0].get("src")), ) - # encode video + + # link Video to Event + link_video_to_event(recording, video, event_id) + + # encode the video encode_video = getattr(encode, ENCODE_VIDEO) encode_video(video.id) +def extract_infos_from_catalog(catalogs, recording): + """Extract event_id, clip_begin and clip_end from xml. + Update Recording if title or creator are defined. + """ + event_id = "" + clip_begin = None + clip_end = None + + for catalog in catalogs: + # check if file exists before parsing + try: + xml_doc = minidom.parse(catalog.get("src")) + except FileNotFoundError as e: + add_comment(recording.id, "Error: %s" % e) + continue + + # if title or creator defined, update Recording + if catalog.get("type") == "dublincore/episode": + change_title(recording, getElementValueByName(xml_doc, "dcterms:title")) + change_user(recording, getElementValueByName(xml_doc, "dcterms:creator")) + event_id = getElementValueByName(xml_doc, "dcterms:course") + + # get begin and end if defined + elif catalog.get("type") == "smil/cutting": + default_begin = getAttributeByName(xml_doc, "video", "clipBegin") + default_end = getAttributeByName(xml_doc, "video", "clipEnd") + clip_begin = ( + str(round(float(default_begin.replace("s", "")), 2)) + if default_begin + else None + ) + clip_end = ( + str(round(float(default_end.replace("s", "")), 2)) + if default_end + else None + ) + return clip_begin, clip_end, event_id + + def change_title(recording, title): + """ + Change recording title. + + Args: + recording (Recording): The recording. + title (str): The title to set. + """ if title != "": recording.title = title recording.save() -def getAttributeByName(xmldoc, tagName, attribute): - elements = xmldoc.getElementsByTagName(tagName) +def change_user(recording, username): + """ + Change recording user. + + Args: + recording (Recording): The recording. + username (str): The username to set. + """ + user = User.objects.filter(username=username).first() if username != "" else None + if user: + recording.user = user + recording.save() + + +def link_video_to_event(recording, video, event_id): + """Associate the Video to the Event. + Add Event's creator as additional owner of the video. + Set same description, type and restrictions. + """ + if isinstance(event_id, int) or event_id.isdigit(): + msg = "*** Associating Event '%s' to Video ***" % (event_id,) + add_comment(recording.id, msg) + evt = Event.objects.filter(id=int(event_id)).first() + + if evt is not None: + evt.videos.add(video) + evt.save() + + video.description = evt.description + video.type = evt.type + video.is_draft = evt.is_draft + if video.owner != evt.owner: + video.additional_owners.add(evt.owner) + if evt.is_draft: + video.password = None + video.is_restricted = False + video.restrict_access_to_groups.clear() + else: + video.password = evt.password + video.is_restricted = evt.is_restricted + video.restrict_access_to_groups.set(evt.restrict_access_to_groups.all()) + + video.save() + + +def getAttributeByName(xml_doc, tag_name, attribute): + """ + Retrieves the value of a specified attribute from the first element with the given tag name in the xml. + + Args: + xml_doc (minidom.Document): The xml doc to search. + tag_name (str): The tag name of the element. + attribute (str): The name of the attribute to get. + + Returns: + str or None: The value of the specified attribute, or None if the attribute or element is not found. + """ + elements = xml_doc.getElementsByTagName(tag_name) if elements and len(elements) > 0: attr = elements[0].getAttribute(attribute) + # don't know why values with 'e' are discarded if attr and "e" not in attr: return attr return None -def getElementsByName(xmldoc, name): +def getElementsByName(xml_doc, name) -> list: + """ + Extracts from xml document the elements with tag name. + And returns type and path of a file for all elements having attributes 'url' and 'type'. + + Args: + xml_doc (minidom.Document): The XML document to search for elements. + name (str): The tag name of the elements to retrieve. + + Returns: + list: A list of dictionaries representing the extracted elements. + Each dictionary contains 'type' and 'src' as keys + """ elements = [] - for element in xmldoc.getElementsByTagName(name): - urlElement = element.getElementsByTagName("url")[0] - if urlElement.firstChild and urlElement.firstChild.data != "": - element_path = urlElement.firstChild.data[ - urlElement.firstChild.data.index(MEDIA_URL) + len(MEDIA_URL) : - ] - src = os.path.join(settings.MEDIA_ROOT, element_path) - if os.path.isfile(src): - elements.append( - { - "type": element.getAttribute("type"), - "src": src, - } - ) + + # Check if the specified tag name is present in the XML document + tag_elements = xml_doc.getElementsByTagName(name) + if not tag_elements: + print(f"Tag name '{name}' not found in the xml") + return elements + + for element in tag_elements: + elements_url = element.getElementsByTagName("url") + + # Check if at least one element is present + if elements_url: + element_url = elements_url[0] + + if element_url.firstChild and element_url.firstChild.data: + url_data = element_url.firstChild.data + if MEDIA_URL in url_data: + element_path = url_data[url_data.index(MEDIA_URL) + len(MEDIA_URL) :] + src = os.path.join(MEDIA_ROOT, element_path) + + # Check if the file exists + if os.path.isfile(src): + elements.append( + {"type": element.getAttribute("type"), "src": src} + ) + return elements -def getElementValueByName(xmldoc, name): - element = xmldoc.getElementsByTagName(name)[0] - if element.firstChild and element.firstChild.data != "": - return element.firstChild.data - return "" +def getElementValueByName(xml_doc, name) -> str: + """ + Gets the value of a xml element. + + Args: + xml_doc(minidom.Element): parsed minidom file . + name(str): element name . + Returns: + str: the value of xml element or empty string + """ + list_elements = xml_doc.getElementsByTagName(name) + if not list_elements: + print(f"element {name} not found in xml") + return "" + element = list_elements[0] + if not isinstance(element.firstChild, minidom.Text): + print(f"element {name} not a minidom.Text") + return "" + return element.firstChild.data diff --git a/pod/recorder/studio_urls_digest.py b/pod/recorder/studio_urls_digest.py new file mode 100644 index 0000000000..04b10c993a --- /dev/null +++ b/pod/recorder/studio_urls_digest.py @@ -0,0 +1,98 @@ +from django.conf.urls import url + +from pod.recorder.views import ( + digest_admin_ng_series, + digest_info_me_json, + digest_ingest_addAttachment, + digest_ingest_addCatalog, + digest_ingest_addDCCatalog, + digest_ingest_addTrack, + digest_ingest_createMediaPackage, + digest_ingest_ingest, + digest_presenter_post, + digest_settings_toml, + digest_available, + digest_studio_static, + digest_hosts_json, + digest_capture_admin, + digest_capture_admin_configuration, +) + +app_name = "recorder_digest" +urlpatterns = [ + url( + r"^services/hosts.json$", + digest_hosts_json, + name="hosts_json", + ), + url( + r"^capture-admin/agents/(?P.+)/configuration$", + digest_capture_admin_configuration, + name="capture_admin_config", + ), + url( + r"^capture-admin/agents/(?P.*)$", + digest_capture_admin, + name="capture_admin_agent", + ), + url( + r"^admin-ng/series/series.json$", + digest_admin_ng_series, + name="admin_ng_series", + ), + url( + r"^services/available.json$", + digest_available, + name="services_available", + ), + url( + r"^presenter_post$", + digest_presenter_post, + name="presenter_post", + ), + url( + r"^settings.toml$", + digest_settings_toml, + name="settings_toml", + ), + url( + r"^info/me.json$", + digest_info_me_json, + name="info_me_json", + ), + url( + r"^static/(?P.*)$", + digest_studio_static, + name="studio_static", + ), + url( + r"^ingest/createMediaPackage$", + digest_ingest_createMediaPackage, + name="ingest_createMediaPackage", + ), + url( + r"^ingest/addDCCatalog$", + digest_ingest_addDCCatalog, + name="ingest_addDCCatalog", + ), + url( + r"^ingest/addAttachment$", + digest_ingest_addAttachment, + name="ingest_addAttachment", + ), + url( + r"^ingest/addTrack$", + digest_ingest_addTrack, + name="ingest_addTrack", + ), + url( + r"^ingest/addCatalog$", + digest_ingest_addCatalog, + name="ingest_addCatalog", + ), + url( + r"^ingest/ingest$", + digest_ingest_ingest, + name="ingest_ingest", + ), +] diff --git a/pod/recorder/tests/test_models.py b/pod/recorder/tests/test_models.py index fef456d455..9bb134bcbb 100644 --- a/pod/recorder/tests/test_models.py +++ b/pod/recorder/tests/test_models.py @@ -16,7 +16,8 @@ class RecorderTestCase(TestCase): ] def setUp(self): - videotype = Type.objects.create(title="others") + """Create models to be tested.""" + other_type = Type.objects.create(title="others") user = User.objects.create(username="pod") user1 = User.objects.create(username="pod1") user2 = User.objects.create(username="pod2") @@ -26,18 +27,22 @@ def setUp(self): user=user, name="recorder1", address_ip="16.3.10.37", - type=videotype, + type=other_type, cursus="0", is_draft=False, is_restricted=True, password="secret", directory="dir1", + salt="pepper", + credentials_login="simplelogin", + credentials_password="randompassword", ) recorder1.additional_users.add(user1) recorder1.additional_users.add(user2) recorder1.restrict_access_to_groups.add(group) - def test_attributs(self): + def test_attributes(self): + """Test model attributes.""" recorder1 = Recorder.objects.get(id=1) self.assertEqual(recorder1.name, "recorder1") self.assertEqual(recorder1.address_ip, "16.3.10.37") @@ -47,43 +52,81 @@ def test_attributs(self): self.assertEqual(recorder1.password, "secret") self.assertEqual(recorder1.additional_users.count(), 2) self.assertEqual(recorder1.restrict_access_to_groups.count(), 1) - print(" ---> test_attributs of RecorderTestCase: OK!") + self.assertEqual(recorder1.salt, "pepper") + self.assertEqual(recorder1.credentials_login, "simplelogin") + self.assertEqual(recorder1.credentials_password, "randompassword") + print(" ---> test_attributes of RecorderTestCase: OK!") + + def test_clean_method(self): + """Test method clean().""" + # credential are not defined: clean is fine + recorder1 = Recorder.objects.get(id=1) + recorder1.credentials_login = "" + recorder1.credentials_password = "" + recorder1.clean() + print(" ---> test_clean_raise_exception of RecorderTestCase: OK!") + + # credentials_login needed if credentials_password set + recorder1 = Recorder.objects.get(id=1) + recorder1.credentials_login = "" + self.assertRaises(ValidationError, recorder1.clean) + print(" ---> test_clean_raise_exception of RecorderTestCase: OK!") + + # credentials_password needed if credentials_login set + recorder1 = Recorder.objects.get(id=1) + recorder1.credentials_password = "" + self.assertRaises(ValidationError, recorder1.clean) + print(" ---> test_clean_raise_exception of RecorderTestCase: OK!") - def test_ipunder(self): + # check exception content + recorder1 = Recorder.objects.get(id=1) + recorder1.salt = "" + recorder1.credentials_login = "" + with self.assertRaises(Exception) as context: + recorder1.clean() + self.assertTrue("credentials_password" in str(context.exception)) + self.assertTrue("credentials_login" in str(context.exception)) + self.assertTrue("salt" in str(context.exception)) + + def test_ip_under(self): + """Test method ipunder().""" recorder1 = Recorder.objects.get(id=1) self.assertEqual(recorder1.ipunder(), "16_3_10_37") - print(" ---> test_ipunder of RecorderTestCase: OK!") + print(" ---> test_ip_under of RecorderTestCase: OK!") def test_delete_object(self): + """Test model delete.""" Recorder.objects.filter(name="recorder1").delete() self.assertEqual(Recorder.objects.all().count(), 0) - print(" ---> test_delete_object of RecorderTestCase: OK!") class RecordingTestCase(TestCase): + """Test case for Pod Recording.""" + fixtures = [ "initial_data.json", ] def setUp(self): - videotype = Type.objects.create(title="others") + """Create models to be tested.""" + other_type = Type.objects.create(title="others") user = User.objects.create(username="pod") recorder1 = Recorder.objects.create( id=1, user=user, name="recorder1", address_ip="16.3.10.37", - type=videotype, + type=other_type, cursus="0", directory="dir1", ) source_file = "/home/pod/files/video.mp4" - type = "video" + video_type = "video" recording = Recording.objects.create( user=user, title="media1", - type=type, + type=video_type, source_file=source_file, recorder=recorder1, ) @@ -91,11 +134,8 @@ def setUp(self): print(" ---> SetUp of RecordingTestCase: OK!") - """ - test attributs - """ - - def test_attributs(self): + def test_attributes(self): + """Test model attributes.""" recording = Recording.objects.get(id=1) recorder = Recorder.objects.get(id=1) self.assertEqual(recording.title, "media1") @@ -106,10 +146,11 @@ def test_attributs(self): self.assertEqual(recording.date_added.year, date.year) self.assertEqual(recording.date_added.month, date.month) self.assertEqual(recording.date_added.day, date.day) - print(" ---> test_attributs of RecordingTestCase: OK!") + print(" ---> test_attributes of RecordingTestCase: OK!") # Testing the two if cases of verify_attibuts method def test_verifying_attributs_fst_cases(self): + """Test method verifying_attributs().""" recording = Recording.objects.get(id=1) recording.type = "" recording.source_file = "" @@ -122,6 +163,7 @@ def test_verifying_attributs_fst_cases(self): # Testing the two elif cases of verify_attibuts method def test_verifying_attributs_snd_cases(self): + """Test method verifying_attributs().""" recording = Recording.objects.get(id=1) recording.type = "something" recording.source_file = "/home/pod/files/somefile.mp4" @@ -133,17 +175,15 @@ def test_verifying_attributs_snd_cases(self): ) def test_clean_raise_exception(self): + """Test method clean().""" recording = Recording.objects.get(id=1) recording.type = "something" recording.save() self.assertRaises(ValidationError, recording.clean) print(" ---> test_clean_raise_exception of RecordingTestCase: OK!") - """ - test delete object - """ - def test_delete_object(self): + """Test method delete().""" Recording.objects.filter(title="media1").delete() self.assertEqual(Recording.objects.all().count(), 0) @@ -151,12 +191,15 @@ def test_delete_object(self): class RecordingFileTreatmentTestCase(TestCase): + """Test case for Pod RecordingFileTreatment.""" + fixtures = [ "initial_data.json", ] def setUp(self): - videotype = Type.objects.create(title="others") + """Create models to be tested.""" + other_type = Type.objects.create(title="others") user1 = User.objects.create(username="pod") recorder1 = Recorder.objects.create( id=1, @@ -164,7 +207,7 @@ def setUp(self): name="recorder1", address_ip="16.3.10.37", cursus="0", - type=videotype, + type=other_type, directory="dir1", ) recording_file = RecordingFileTreatment.objects.create( @@ -175,11 +218,8 @@ def setUp(self): recording_file.save() print(" ---> SetUp of RecordingFileTestCase: OK!") - """ - test attributs - """ - - def test_attributs(self): + def test_attributes(self): + """Test model attributes.""" recording_file = RecordingFileTreatment.objects.get(id=1) recorder = Recorder.objects.get(id=1) self.assertEqual(recording_file.type, "video") @@ -190,13 +230,10 @@ def test_attributs(self): self.assertEqual(recording_file.date_added.year, date.year) self.assertEqual(recording_file.date_added.month, date.month) self.assertEqual(recording_file.date_added.day, date.day) - print(" ---> test_attributs of RecordingFileTreatmentTestCase: OK!") - - """ - test delete object - """ + print(" ---> test_attributes of RecordingFileTreatmentTestCase: OK!") def test_delete_object(self): + """Test method delete().""" filepath = "/home/pod/files/somefile.mp4" RecordingFileTreatment.objects.filter(file=filepath).delete() self.assertEqual(RecordingFileTreatment.objects.all().count(), 0) @@ -205,12 +242,15 @@ def test_delete_object(self): class RecordingFileTestCase(TestCase): + """Test case for Pod RecordingFile.""" + fixtures = [ "initial_data.json", ] def setUp(self): - videotype = Type.objects.create(title="others") + """Create models to be tested.""" + other_type = Type.objects.create(title="others") user1 = User.objects.create(username="pod") recorder1 = Recorder.objects.create( id=1, @@ -218,7 +258,7 @@ def setUp(self): name="recorder1", address_ip="16.3.10.37", cursus="0", - type=videotype, + type=other_type, directory="dir1", ) recording_file = RecordingFile.objects.create(recorder=recorder1) @@ -226,20 +266,14 @@ def setUp(self): recording_file.save() print(" ---> SetUp of RecordingFileTestCase: OK!") - """ - test attributs - """ - - def test_attributs(self): + def test_attributes(self): + """Test model attributes.""" recording_file = RecordingFile.objects.get(id=1) self.assertEqual(recording_file.file, "/home/pod/files/somefile.mp4") print(" ---> test_attributs of RecordingFileTestCase: OK!") - """ - test delete object - """ - def test_delete_object(self): + """Test method delete().""" filepath = "/home/pod/files/somefile.mp4" RecordingFile.objects.filter(file=filepath).delete() self.assertEqual(RecordingFile.objects.all().count(), 0) diff --git a/pod/recorder/tests/test_plugins.py b/pod/recorder/tests/test_plugins.py index f9e553155b..37346b33d0 100644 --- a/pod/recorder/tests/test_plugins.py +++ b/pod/recorder/tests/test_plugins.py @@ -3,11 +3,15 @@ import os import shutil import importlib +from xml.dom import minidom + from django.conf import settings from django.test import TestCase from django.contrib.auth.models import User from pod.video.models import Video, Type from ..models import Recording, Recorder +from ...live.models import Event, Building, Broadcaster +from ...main.settings import BASE_DIR VIDEO_TEST = getattr(settings, "VIDEO_TEST", "pod/main/static/video_test/pod.mp4") @@ -25,6 +29,7 @@ class PluginVideoTestCase(TestCase): def setUp(self): mediatype = Type.objects.create(title="others") + Type.objects.create(title="second") user = User.objects.create(username="pod", is_staff=True) # Setup recorder and recording for Video recorder1 = Recorder.objects.create( @@ -94,7 +99,7 @@ def test_type_audiovideocast_published_attributs(self): recording = Recording.objects.get(id=2) recorder = recording.recorder shutil.copyfile(AUDIOVIDEOCAST_TEST, recording.source_file) - mod = importlib.import_module("pod.recorder.plugins.type_%s" % ("audiovideocast")) + mod = importlib.import_module("pod.recorder.plugins.type_%s" % "audiovideocast") nbnow = Video.objects.all().count() nbtest = nbnow + 1 mod.encode_recording(recording) @@ -117,3 +122,201 @@ def test_type_audiovideocast_published_attributs(self): " ---> test_type_video_published_attributs " "of PluginAudioVideoCastTestCase: OK!" ) + + def test_change_title(self): + """Test method change_title.""" + from ..plugins.type_studio import change_title + + recording = Recording.objects.get(id=1) + title = "A new title" + self.assertNotEquals(recording.title, title) + + change_title(recording, title) + recording = Recording.objects.get(id=1) + self.assertEquals(recording.title, title) + print(" ---> test_change_title of PluginVideoTestCase: OK!") + + def test_change_user(self): + """Test method change_user.""" + from ..plugins.type_studio import change_user + + recording = Recording.objects.get(id=1) + user2 = User.objects.create(username="another_user", is_staff=True) + self.assertNotEquals(recording.user, user2) + + change_user(recording, user2.username) + recording = Recording.objects.get(id=1) + self.assertEquals(recording.user, user2) + print(" ---> test_change_user of PluginVideoTestCase: OK!") + + def test_link_video_to_event(self): + """Test method link_video_to_event.""" + from ..plugins.type_studio import link_video_to_event + + user_pod = User.objects.filter(id=1).first() + recording = Recording.objects.get(id=1) + video = Video.objects.create( + title="video", + owner=user_pod, + video="test.mp4", + type=Type.objects.get(id=1), + is_restricted=True, + is_draft=False, + password="pod1234pod", + ) + building = Building.objects.create(name="building1") + broadcaster = Broadcaster.objects.create( + name="broadcaster1", + building=building, + ) + user2 = User.objects.create(username="another_user", is_staff=True) + event_draft = Event.objects.create( + title="event1", + owner=user2, + is_draft=True, + type=Type.objects.get(id=2), + broadcaster=broadcaster, + description="random desc", + ) + event_not_draft = Event.objects.create( + title="event2", + owner=user2, + is_draft=False, + type=Type.objects.get(id=2), + broadcaster=broadcaster, + description="event_draft desc", + ) + + self.assertTrue(event_draft.videos.count() == 0) + self.assertNotIn(event_draft.owner, video.additional_owners.all()) + + self.assertNotEqual(event_draft.description, video.description) + self.assertNotEqual(event_draft.is_draft, video.is_draft) + self.assertNotEqual(event_draft.type, video.type) + + # Call the method + link_video_to_event(recording, video, event_draft.id) + + self.assertEqual(event_draft.description, video.description) + self.assertEqual(event_draft.is_draft, video.is_draft) + self.assertEqual(event_draft.type, video.type) + self.assertIsNone(video.password) + self.assertFalse(video.is_restricted) + self.assertEqual(video.restrict_access_to_groups.count(), 0) + + # Test the association + self.assertTrue(event_draft.videos.count() == 1) + + # Test Video additional users contains Event's owner + self.assertIn(event_draft.owner, video.additional_owners.all()) + + # Test persistence in db + event_from_db = Event.objects.get(id=1) + self.assertTrue(event_from_db.videos.count() == 1) + + video_from_db = Video.objects.get(id=video.id) + self.assertIn(event_from_db.owner, video_from_db.additional_owners.all()) + + # With not draft event + link_video_to_event(recording, video, event_not_draft.id) + self.assertEqual(event_not_draft.is_draft, video.is_draft) + self.assertEqual(event_not_draft.is_restricted, video.is_restricted) + + print(" ---> test_link_video_to_event of PluginVideoTestCase: OK!") + + def test_get_attribute_by_name(self): + """Test method getAttributeByName.""" + from ..plugins.type_studio import getAttributeByName + + xml_content = ( + '' + '' + '' + "" + ) + + xml_doc = minidom.parseString(xml_content) + + # Test case 1: Retrieve the attribute 'id' from the first element with tag name + id_attribute = getAttributeByName(xml_doc, "mytag", "id") + self.assertEqual(id_attribute, "1") + + # Test case 2: Test the exclusion of value containing the letter 'e' + excluded_attribute = getAttributeByName(xml_doc, "mytag", "name") + self.assertIsNone(excluded_attribute) + + # Test case 3: Non-existing tag name 'nonexistent' + non_existing_attribute = getAttributeByName(xml_doc, "nonexistent", "id") + self.assertIsNone(non_existing_attribute) + + # Test case 4: Non-existing attribute from the first element + non_existing_attribute_2 = getAttributeByName(xml_doc, "mytag", "nonexistent") + self.assertIsNone(non_existing_attribute_2) + print(" ---> test_get_attribute_by_name of PluginVideoTestCase: OK!") + + def test_get_elements_by_name(self): + """Test method getElementsByName.""" + from ..plugins.type_studio import getElementsByName + + __MEDIA_TMP_FOLDER__ = os.path.join("media", "test_plugins") + __MEDIA_TMP_PATH__ = os.path.join(BASE_DIR, __MEDIA_TMP_FOLDER__) + image_url = os.path.join(__MEDIA_TMP_FOLDER__, "image.jpg") + video_url = os.path.join(__MEDIA_TMP_FOLDER__, "video.mp4") + + # Test case 1: xml document without the expected tag + xml_content = '' + parsed = minidom.parseString(xml_content) + elements = getElementsByName(parsed, "element") + self.assertEqual(elements, []) + + # Create directory and sample files + os.makedirs(__MEDIA_TMP_PATH__) + with open(os.path.join(BASE_DIR, image_url), "w") as image_file: + image_file.write("Sample image content") + + with open(os.path.join(BASE_DIR, video_url), "w") as video_file: + video_file.write("Sample video content") + + # Test case 2: xml document with expected tags + xml_content = ( + f'' + f"" + f'/{image_url}' + f'/{video_url}' + f"" + ) + parsed = minidom.parseString(xml_content) + + # Gets elements + elements = getElementsByName(parsed, "mytag") + + # Remove directory and sample files before test ends + os.remove(os.path.join(BASE_DIR, image_url)) + os.remove(os.path.join(BASE_DIR, video_url)) + os.removedirs(__MEDIA_TMP_PATH__) + + expected_elements = [ + {"type": "image", "src": os.path.join(BASE_DIR, image_url)}, + {"type": "video", "src": os.path.join(BASE_DIR, video_url)}, + ] + + self.assertEqual(elements, expected_elements) + print(" ---> test_get_elements_by_name of PluginVideoTestCase: OK!") + + def test_getElementValueByName(self): + """Test method getElementValueByName.""" + from ..plugins.type_studio import getElementValueByName + + xml = ( + '' + '' + "test" + "randomuser" + "" + ) + parsed = minidom.parseString(xml) + self.assertEqual("", getElementValueByName(parsed, "myNode")) + self.assertEqual("randomuser", getElementValueByName(parsed, "dcterms:creator")) + self.assertEqual("", getElementValueByName(parsed, "notexisting")) + print(" ---> test_getElementValueByName of PluginVideoTestCase: OK!") diff --git a/pod/recorder/tests/test_utils.py b/pod/recorder/tests/test_utils.py new file mode 100644 index 0000000000..084b0933a4 --- /dev/null +++ b/pod/recorder/tests/test_utils.py @@ -0,0 +1,311 @@ +"""Unit tests for recorder utils.""" + +import os +import time +from xml.dom import minidom + +from django.conf import settings +from django.contrib.auth.models import User +from django.test import RequestFactory, TestCase + +from pod.video.models import Type +from ..models import Recorder, Recording +from ..utils import ( + add_comment, + studio_clean_old_entries, + get_id_media, + create_xml_element, + create_digest_auth_response, + get_auth_headers_as_dict, + digest_is_valid, + compute_digest, +) +from ...settings import BASE_DIR + +MEDIA_ROOT = getattr(settings, "MEDIA_ROOT", os.path.join(BASE_DIR, "media")) +OPENCAST_FILES_DIR = getattr(settings, "OPENCAST_FILES_DIR", "opencast-files") + + +class UtilsTestCase(TestCase): + """Test case for utils methods.""" + + fixtures = [ + "initial_data.json", + ] + + def setUp(self): + """Create models to be tested.""" + r_type = Type.objects.create(title="others") + user = User.objects.create(username="pod") + recorder1 = Recorder.objects.create( + id=1, + user=user, + name="rec1", + address_ip="127.1.1.1", + type=r_type, + cursus="0", + directory="dir1", + ) + source_file = "/home/pod/files/video.mp4" + v_type = "video" + recording = Recording.objects.create( + user=user, + title="media1", + type=v_type, + source_file=source_file, + recorder=recorder1, + comment="line1", + ) + recording.save() + + print(" ---> SetUp of UtilsTestCase: OK!") + + def test_add_comment(self): + """Test method add_comment().""" + recording = Recording.objects.get(id=1) + first_comment = recording.comment + new_comment = "line2" + add_comment(recording.id, new_comment) + + recording = Recording.objects.get(id=1) + self.assertEqual(recording.comment, first_comment + "\n" + new_comment) + print(" ---> test_add_comment of UtilsTestCase: OK !") + + def test_clean_old_files(self): + """Test case for cleaning old folders in opencast-files directory.""" + + opencast_dir_path = os.path.join(MEDIA_ROOT, OPENCAST_FILES_DIR) + + # create a directory with a text file + opencast_test_dir = os.path.join(opencast_dir_path, "test_dir") + os.makedirs(opencast_test_dir, exist_ok=True) + with open(os.path.join(opencast_test_dir, "dummy.txt"), "w") as f: + f.write("dummy text") + + # create a file + opencast_test_file = os.path.join(opencast_dir_path, "test_file.txt") + with open(opencast_test_file, "w") as f: + f.write("another dummy text") + + # must exist + self.assertTrue(os.path.exists(opencast_test_dir)) + self.assertTrue(os.path.exists(opencast_test_file)) + + # and dir and file are not deleted + studio_clean_old_entries() + self.assertTrue(os.path.exists(opencast_test_dir)) + self.assertTrue(os.path.exists(opencast_test_file)) + + # change creation and modification datetime of the dir and file + new_time = time.time() - 8 * 86400 + os.utime(opencast_test_dir, (new_time, new_time)) + os.utime(opencast_test_file, (new_time, new_time)) + + # test that dir and file are deleted + studio_clean_old_entries() + self.assertFalse(os.path.exists(opencast_test_dir)) + self.assertFalse(os.path.exists(opencast_test_file)) + + print(" ---> test_clean_old_files of UtilsTestCase: OK !") + + def test_get_id_media(self): + """Test method get_id_media().""" + + # Test with no data + request = RequestFactory().post("/") + id_media = get_id_media(request) + self.assertIsNone(id_media) + + # Test with invalid data + request = RequestFactory().post("/", {"mediaPackage": ""}) + id_media = get_id_media(request) + self.assertIsNone(id_media) + + # Test with invalid data + request = RequestFactory().post("/", {"mediaPackage": "{}"}) + id_media = get_id_media(request) + self.assertIsNone(id_media) + + # Test with a mediaPackage XML + media_package_xml = """ + + xxx + + """ + request = RequestFactory().post("/", {"mediaPackage": media_package_xml}) + id_media = get_id_media(request) + self.assertEqual(id_media, "1111") + + print(" ---> test_get_id_media of UtilsTestCase: OK !") + + def test_create_xml_element(self): + """Test method create_xml_element().""" + + # Create a mock mediaPackage_content + mediaPackage_content = minidom.Document() + + element_name = "not_track" + type_name = "type" + mimetype = "application/pdf" + url_text = "https://example.com/document.pdf" + element = create_xml_element( + mediaPackage_content, element_name, type_name, mimetype, url_text + ) + + # Check filename does not exist + self.assertFalse(element.hasAttribute("filename")) + + # Call the function + element_name = "track" + type_name = "audio" + mimetype = "audio/mpeg" + url_text = "https://example.com/audio.mp3" + opencast_filename = "audio.mp3" + element = create_xml_element( + mediaPackage_content, + element_name, + type_name, + mimetype, + url_text, + opencast_filename, + ) + + # Check that the element is created correctly + self.assertEqual(element.nodeName, element_name) + self.assertEqual(element.getAttribute("type"), type_name) + self.assertEqual(element.getAttribute("filename"), opencast_filename) + + # Check that the mimetype and url child elements are created and appended + self.assertEqual(len(element.getElementsByTagName("mimetype")), 1) + self.assertEqual(len(element.getElementsByTagName("url")), 1) + + # Check the content of the mimetype and url elements + mimetype_element = element.getElementsByTagName("mimetype")[0] + url_element = element.getElementsByTagName("url")[0] + self.assertEqual(mimetype_element.firstChild.nodeValue, mimetype) + self.assertEqual(url_element.firstChild.nodeValue, url_text) + + # Check the optional "live" element for track + live_element = element.getElementsByTagName("live") + self.assertEqual(len(live_element), 1) + self.assertEqual(live_element[0].firstChild.nodeValue, "false") + + +class DigestTestCase(TestCase): + """Test case for Pod recorder digest methods.""" + + fixtures = [ + "initial_data.json", + ] + + def setUp(self): + """Create models to be tested.""" + r_type = Type.objects.create(title="others") + user = User.objects.create(username="pod") + recorder = Recorder.objects.create( + id=1, + user=user, + name="recorder1", + address_ip="127.1.1.1", + type=r_type, + directory="dir1", + recording_type="video", + salt="pepper", + credentials_login="inner_login", + ) + recorder.save() + print(" ---> SetUp of UtilsTestCase: OK!") + + def test_create_digest_auth_response(self): + """Test method create_digest_auth_response().""" + request = RequestFactory().get("/") + + # test 403 + request.META["REMOTE_ADDR"] = "999.99.99.99" + response = create_digest_auth_response(request) + self.assertEqual(response.status_code, 403) + + # test 401 + recorder = Recorder.objects.get(id=1) + request.META["REMOTE_ADDR"] = recorder.address_ip + response = create_digest_auth_response(request) + + self.assertEqual(response.status_code, 401) + self.assertTrue("WWW-Authenticate" in response) + self.assertIn(recorder.salt, response["WWW-Authenticate"]) + + print(" ---> test_create_digest_auth_response of DigestTestCase: OK!") + + def test_get_auth_headers_as_dict(self): + """Test method get_auth_headers_as_dict().""" + + # test no auth_headers + request = RequestFactory().get("/") + response = get_auth_headers_as_dict(request) + self.assertEqual(response, {}) + + # headers with key Authorization + header = {"HTTP_Authorization": 'a="1", b="2"'} + request = RequestFactory().get("/", {}, **header) + response = get_auth_headers_as_dict(request) + self.assertEqual(response, {"a": "1", "b": "2"}) + + print(" ---> test_get_auth_headers_as_dict of DigestTestCase: OK!") + + def test_digest_is_valid(self): + """Test digest validation.""" + + # test no auth_headers + request = RequestFactory().get("/") + response = digest_is_valid(request) + self.assertFalse(response) + + # request with Authorization header but needed keys missing (missing data to compute hash) + header = {"HTTP_Authorization": 'a="1"'} + request = RequestFactory().get("/", {}, **header) + response = digest_is_valid(request) + self.assertFalse(response) + + # request with Authorization header having keys + header = {"HTTP_Authorization": 'username="", realm="", uri="", response=""'} + request = RequestFactory().get("/", {}, **header) + + # test ip does not match any Recorder address_ip + request.META["REMOTE_ADDR"] = "999.99.99.99" + response = digest_is_valid(request) + self.assertFalse(response) + + # test ip match but recorder.credentials_login is wrong + recorder = Recorder.objects.get(id=1) + request.META["REMOTE_ADDR"] = recorder.address_ip + response = digest_is_valid(request) + self.assertFalse(response) + + # compute digest + login = recorder.credentials_login + realm = "any" + passw = recorder.credentials_password + method = "GET" + uri = "/" + salt = recorder.salt + computed = compute_digest(login, realm, passw, method, uri, salt) + + # Request Ip and Username match Recorder address_ip and credentials_login + # Computed Hash sent by the client must be the same as th one calculated by the server + header = { + "HTTP_Authorization": 'username="' + + login + + '",realm="' + + realm + + '",uri="' + + uri + + '",response="' + + computed + + '"' + } + + request = RequestFactory().get(uri, {}, **header) + request.META["REMOTE_ADDR"] = recorder.address_ip + response = digest_is_valid(request) + self.assertTrue(response) + print(" ---> test_digest_is_valid of DigestTestCase: OK!") diff --git a/pod/recorder/tests/test_views.py b/pod/recorder/tests/test_views.py index 4a58f92e8a..e571b9de64 100644 --- a/pod/recorder/tests/test_views.py +++ b/pod/recorder/tests/test_views.py @@ -1,30 +1,28 @@ """Unit tests for recorder views.""" import hashlib +import os +from http import HTTPStatus +from xml.dom import minidom from django.conf import settings -from django.urls import reverse -from django.test import TestCase -from django.test import Client -from django.contrib.sites.models import Site from django.contrib.auth.models import User +from django.contrib.sites.models import Site from django.core.exceptions import PermissionDenied from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client +from django.test import TestCase +from django.urls import reverse -from ..models import Recorder, Recording, RecordingFileTreatment from pod.video.models import Type - -from xml.dom import minidom - from .. import views -from http import HTTPStatus -import os +from ..models import Recorder, Recording, RecordingFileTreatment OPENCAST_FILES_DIR = getattr(settings, "OPENCAST_FILES_DIR", "opencast-files") OPENCAST_DEFAULT_PRESENTER = getattr(settings, "OPENCAST_DEFAULT_PRESENTER", "mid") -class recorderViewsTestCase(TestCase): +class RecorderViewsTestCase(TestCase): """Test case for Pod recorder views.""" fixtures = [ @@ -32,6 +30,7 @@ class recorderViewsTestCase(TestCase): ] def setUp(self): + """Create models to be tested.""" site = Site.objects.get(id=1) videotype = Type.objects.create(title="others") user = User.objects.create(username="pod", password="podv3") @@ -52,14 +51,16 @@ def setUp(self): user.owner.sites.add(Site.objects.get_current()) user.owner.save() - print(" ---> SetUp of recorderViewsTestCase: OK!") + print(" ---> SetUp of RecorderViewsTestCase: OK!") def test_add_recording(self): + """Test method add_recording().""" + self.client = Client() self.user = User.objects.get(username="pod") self.client.force_login(self.user) url = reverse("record:add_recording") - response = self.client.get(url) + self.client.get(url) self.assertRaises(PermissionDenied) # No mediapath and user is not SU self.user.is_superuser = True @@ -79,14 +80,16 @@ def test_add_recording(self): self.assertTemplateUsed(response, "recorder/add_recording.html") - print(" ---> test_add_recording of recorderViewsTestCase: OK!") + print(" ---> test_add_recording of RecorderViewsTestCase: OK!") def test_claim_recording(self): + """Test method claim_recording().""" + self.client = Client() self.user = User.objects.get(username="pod") self.client.force_login(self.user) url = reverse("record:claim_record") - response = self.client.get(url) + self.client.get(url) self.assertRaises(PermissionDenied) # No mediapath and user is not SU self.user.is_superuser = True @@ -101,19 +104,21 @@ def test_claim_recording(self): self.assertTemplateUsed(response, "recorder/claim_record.html") - print(" ---> test_claim_record of recorderViewsTestCase: OK!") + print(" ---> test_claim_record of RecorderViewsTestCase: OK!") def test_delete_record(self): + """Test method delete_recording().""" + self.client = Client() self.user = User.objects.get(username="pod") self.client.force_login(self.user) url = reverse("record:delete_record", kwargs={"id": 1}) - response = self.client.get(url) + self.client.get(url) self.assertRaises(PermissionDenied) self.user.is_staff = True self.user.save() - response = self.client.get(url) + self.client.get(url) self.assertRaises(PermissionDenied) self.user.is_superuser = True @@ -127,9 +132,11 @@ def test_delete_record(self): self.assertTemplateUsed(response, "recorder/record_delete.html") - print(" ---> test_delete_record recorderViewsTestCase: OK!") + print(" ---> test_delete_record RecorderViewsTestCase: OK!") def test_recorder_notify(self): + """Test method recorder_notify().""" + self.client = Client() record = Recorder.objects.get(id=1) @@ -158,15 +165,18 @@ def test_recorder_notify(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"ok") - print(" ---> test_record_notify of recorderViewsTestCase: OK!") + print(" ---> test_record_notify of RecorderViewsTestCase: OK!") -class studio_podTestView(TestCase): +class StudioPodTestView(TestCase): + """Test case for Pod studio views.""" + fixtures = [ "initial_data.json", ] def create_index_file(self): + """Create and write a xml file.""" text = """ @@ -188,29 +198,41 @@ def create_index_file(self): file.close() def setUp(self): - User.objects.create(username="pod", password="pod1234pod") - print(" ---> SetUp of studio_podTestView: OK!") + """Create models to be tested.""" + User.objects.create(username="pod") + print(" ---> SetUp of StudioPodTestView: OK!") - def test_studio_podTestView_get_request(self): + def test_StudioPodTestView_get_request(self): + """Test view studio_pod.""" self.create_index_file() self.client = Client() url = reverse("recorder:studio_pod", kwargs={}) + + # not logged response = self.client.get(url) self.assertEqual(response.status_code, 302) + + # user logged in self.user = User.objects.get(username="pod") self.client.force_login(self.user) response = self.client.get(url) self.assertEqual(response.status_code, HTTPStatus.OK) - print(" ---> test_studio_podTestView_get_request of openCastTestView: OK!") + print(" ---> test_StudioPodTestView_get_request of openCastTestView: OK!") + + def test_StudioPodTestView_get_request_restrict(self): + """Test view studio_pod.""" - def test_studio_podTestView_get_request_restrict(self): views.__REVATSO__ = True # override setting value to test self.create_index_file() - self.client = Client() url = reverse("recorder:studio_pod", kwargs={}) + self.client = Client() + + # not logged response = self.client.get(url) self.assertEqual(response.status_code, 302) + + # user logged in self.user = User.objects.get(username="pod") self.client.force_login(self.user) response = self.client.get(url) @@ -221,16 +243,21 @@ def test_studio_podTestView_get_request_restrict(self): self.assertEqual(response.status_code, HTTPStatus.OK) views.__REVATSO__ = False print( - " ---> test_studio_podTestView_get_request_restrict ", - "of studio_podTestView: OK!", + " ---> test_StudioPodTestView_get_request_restrict ", + "of StudioPodTestView: OK!", ) def test_studio_presenter_post(self): + """Test view presenter_post.""" + self.client = Client() url = reverse("recorder:presenter_post", kwargs={}) - response = self.client.get(url) + + # not logged + self.client.get(url) self.assertRaises(PermissionDenied) + # user logged in self.user = User.objects.get(username="pod") self.user.is_staff = True self.user.save() @@ -246,14 +273,19 @@ def test_studio_presenter_post(self): response = self.client.post(url, {"presenter": "mid"}) self.assertEqual(response.status_code, 200) - print(" --> test_studio_presenter_post of studio_podTestView", ": OK!") + print(" --> test_studio_presenter_post of StudioPodTestView: OK!") def test_studio_info_me_json(self): + """Test view info_me_json.""" + self.client = Client() url = reverse("recorder:info_me_json", kwargs={}) - response = self.client.get(url) + + # not logged + self.client.get(url) self.assertRaises(PermissionDenied) + # user logged in self.user = User.objects.get(username="pod") self.user.is_staff = True self.user.save() @@ -263,14 +295,19 @@ def test_studio_info_me_json(self): self.assertTrue(b"ROLE_ADMIN" in response.content) self.assertEqual(response.status_code, 200) - print(" --> test_studio_info_me_json of studio_podTestView", ": OK!") + print(" --> test_studio_info_me_json of StudioPodTestView: OK!") def test_studio_ingest_createMediaPackage(self): + """Test view ingest_createMediaPackage.""" + self.client = Client() url = reverse("recorder:ingest_createMediaPackage", kwargs={}) - response = self.client.get(url) + + # not logged + self.client.get(url) self.assertRaises(PermissionDenied) + # user logged in self.user = User.objects.get(username="pod") self.user.is_staff = True self.user.save() @@ -296,11 +333,11 @@ def test_studio_ingest_createMediaPackage(self): mediapackage = mediaPackage_content.getElementsByTagName("mediapackage")[0] self.assertEqual(mediapackage.getAttribute("id"), idMedia) - print( - " --> test_studio_ingest_createMediaPackage of studio_podTestView", ": OK!" - ) + print(" --> test_studio_ingest_createMediaPackage of StudioPodTestView: OK!") def test_studio_ingest_createMediaPackage_with_presenter(self): + """Test view presenter_post.""" + self.client = Client() self.user = User.objects.get(username="pod") self.user.is_staff = True @@ -339,16 +376,21 @@ def test_studio_ingest_createMediaPackage_with_presenter(self): print( " --> test_studio_ingest_createMediaPackage_with_presenter" - + " of studio_podTestView", + + " of StudioPodTestView", ": OK!", ) def test_studio_ingest_addDCCatalog(self): + """Test view ingest_addDCCatalog.""" + self.client = Client() url_addDCCatalog = reverse("recorder:ingest_addDCCatalog", kwargs={}) - response = self.client.get(url_addDCCatalog) + + # not logged + self.client.get(url_addDCCatalog) self.assertRaises(PermissionDenied) + # user logged in self.user = User.objects.get(username="pod") self.user.is_staff = True self.user.save() @@ -414,14 +456,19 @@ def test_studio_ingest_addDCCatalog(self): self.assertTrue(catalog) self.assertEqual(catalog.getAttribute("type"), "dublincore/episode") - print(" --> test_studio_ingest_addDCCatalog of studio_podTestView", ": OK!") + print(" --> test_studio_ingest_addDCCatalog of StudioPodTestView: OK!") def test_studio_ingest_addAttachment(self): + """Test view ingest_addAttachment.""" + self.client = Client() url_addAttachment = reverse("recorder:ingest_addAttachment", kwargs={}) + + # user not logged response = self.client.get(url_addAttachment) self.assertRaises(PermissionDenied) + # user logged in self.user = User.objects.get(username="pod") self.user.is_staff = True self.user.save() @@ -475,14 +522,19 @@ def test_studio_ingest_addAttachment(self): self.assertTrue(attachment) self.assertEqual(attachment.getAttribute("type"), "security/xacml+episode") - print(" --> test_studio_ingest_addAttachment of studio_podTestView", ": OK!") + print(" --> test_studio_ingest_addAttachment of StudioPodTestView: OK!") def test_studio_ingest_addTrack(self): + """Test view ingest_addTrack.""" + self.client = Client() url_addTrack = reverse("recorder:ingest_addTrack", kwargs={}) + + # user not logged response = self.client.get(url_addTrack) self.assertRaises(PermissionDenied) + # user logged in self.user = User.objects.get(username="pod") self.user.is_staff = True self.user.save() @@ -546,14 +598,19 @@ def test_studio_ingest_addTrack(self): # chek if mediapackage file exist self.assertTrue(os.path.exists(video_file)) - print(" --> test_studio_ingest_addTrack of studio_podTestView", ": OK!") + print(" --> test_studio_ingest_addTrack of StudioPodTestView: OK!") def test_studio_ingest_addCatalog(self): + """Test view ingest_addCatalog.""" + self.client = Client() url_addCatalog = reverse("recorder:ingest_addCatalog", kwargs={}) + + # not logged response = self.client.get(url_addCatalog) self.assertRaises(PermissionDenied) + # user logged in self.user = User.objects.get(username="pod") self.user.is_staff = True self.user.save() @@ -612,14 +669,19 @@ def test_studio_ingest_addCatalog(self): cutting_file = os.path.join(mediaPackage_dir, cutting.name) self.assertTrue(os.path.exists(cutting_file)) - print(" --> test_studio_ingest_addCatalog of studio_podTestView", ": OK!") + print(" --> test_studio_ingest_addCatalog of StudioPodTestView: OK!") def test_studio_ingest_ingest(self): + """Test view ingest_ingest.""" + self.client = Client() url_ingest = reverse("recorder:ingest_ingest", kwargs={}) + + # not logged response = self.client.get(url_ingest) self.assertRaises(PermissionDenied) + # user logged in self.user = User.objects.get(username="pod") self.user.is_staff = True self.user.save() @@ -685,4 +747,143 @@ def test_studio_ingest_ingest(self): # check if recording object exist self.assertTrue(recording.first()) - print(" --> test_studio_ingest_ingest of studio_podTestView", ": OK!") + print(" --> test_studio_ingest_ingest of StudioPodTestView: OK!") + + +class StudioDigestViews(TestCase): + """Test case for Pod studio views with Digest auth.""" + + def test_digest_presenter_post(self): + """Test Digest restriction on view presenter_post.""" + + self.client = Client() + url = reverse("recorder_digest:presenter_post", kwargs={}) + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_studio_pod of StudioDigestViews: OK!") + + def test_digest_studio_static(self): + """Test Digest restriction on view studio_static.""" + + self.client = Client() + url = reverse("recorder_digest:studio_static", kwargs={"file": "test"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_studio_static of StudioDigestViews: OK!") + + def test_digest_settings_toml(self): + """Test Digest restriction on view settings_toml.""" + + self.client = Client() + url = reverse("recorder_digest:settings_toml", kwargs={}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_settings_toml of StudioDigestViews: OK!") + + def test_digest_info_me_json(self): + """Test Digest restriction on view info_me_json.""" + + self.client = Client() + url = reverse("recorder_digest:info_me_json", kwargs={}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_info_me_json of StudioDigestViews: OK!") + + def test_digest_ingest_createMediaPackage(self): + """Test Digest restriction on view ingest_createMediaPackage.""" + + self.client = Client() + url = reverse("recorder_digest:ingest_createMediaPackage", kwargs={}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_ingest_createMediaPackage of StudioDigestViews: OK!") + + def test_digest_ingest_addDCCatalog(self): + """Test Digest restriction on view ingest_addDCCatalog.""" + + self.client = Client() + url = reverse("recorder_digest:ingest_addDCCatalog", kwargs={}) + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_ingest_addDCCatalog of StudioDigestViews: OK!") + + def test_digest_ingest_addAttachment(self): + """Test Digest restriction on view ingest_addAttachment.""" + + self.client = Client() + url = reverse("recorder_digest:ingest_addAttachment", kwargs={}) + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_ingest_addAttachment of StudioDigestViews: OK!") + + def test_digest_ingest_addTrack(self): + """Test Digest restriction on view ingest_addTrack.""" + + self.client = Client() + url = reverse("recorder_digest:ingest_addTrack", kwargs={}) + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_ingest_addTrack of StudioDigestViews: OK!") + + def test_digest_ingest_addCatalog(self): + """Test Digest restriction on view ingest_addCatalog.""" + + self.client = Client() + url = reverse("recorder_digest:ingest_addCatalog", kwargs={}) + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_ingest_addCatalog of StudioDigestViews: OK!") + + def test_digest_ingest_ingest(self): + """Test Digest restriction on view ingest_ingest.""" + + self.client = Client() + url = reverse("recorder_digest:ingest_ingest", kwargs={}) + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_ingest_ingest of StudioDigestViews: OK!") + + def test_digest_hosts_json(self): + """Test Digest restriction on view hosts_json.""" + + self.client = Client() + url = reverse("recorder_digest:hosts_json", kwargs={}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_hosts_json of StudioDigestViews: OK!") + + def test_digest_capture_admin_config(self): + """Test Digest restriction on view capture_admin_config.""" + + self.client = Client() + url = reverse("recorder_digest:capture_admin_config", kwargs={"name": "test"}) + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_capture_admin_config of StudioDigestViews: OK!") + + def test_digest_capture_admin(self): + """Test Digest restriction on view capture_admin_agent.""" + + self.client = Client() + url = reverse("recorder_digest:capture_admin_agent", kwargs={"name": "test"}) + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_capture_admin of StudioDigestViews: OK!") + + def test_digest_admin_ng_series(self): + """Test Digest restriction on view admin_ng_series.""" + + self.client = Client() + url = reverse("recorder_digest:admin_ng_series", kwargs={}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_admin_ng_series of StudioDigestViews: OK!") + + def test_digest_services_available(self): + """Test Digest restriction on view services_available.""" + + self.client = Client() + url = reverse("recorder_digest:services_available", kwargs={}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + print(" ---> test_digest_services_available of StudioDigestViews: OK!") diff --git a/pod/recorder/utils.py b/pod/recorder/utils.py index bb0435407f..9d1b31ec9f 100644 --- a/pod/recorder/utils.py +++ b/pod/recorder/utils.py @@ -1,15 +1,21 @@ """Esup-Pod recorder utilities.""" +import hashlib +import os +import re import shutil import time -import os import uuid -from .models import Recording -from django.conf import settings from xml.dom import minidom + +from django.conf import settings from django.core.exceptions import PermissionDenied from django.http import HttpResponse +from .models import Recording, Recorder +from ..settings import BASE_DIR + +MEDIA_ROOT = getattr(settings, "MEDIA_ROOT", os.path.join(BASE_DIR, "media")) OPENCAST_FILES_DIR = getattr(settings, "OPENCAST_FILES_DIR", "opencast-files") MEDIA_URL = getattr(settings, "MEDIA_URL", "/media/") @@ -28,7 +34,7 @@ def studio_clean_old_entries(): The function removes entries that are older than 7 days from the opencast folder in the media root. """ - folder_to_clean = os.path.join(settings.MEDIA_ROOT, OPENCAST_FILES_DIR) + folder_to_clean = os.path.join(MEDIA_ROOT, OPENCAST_FILES_DIR) now = time.time() for entry in os.listdir(folder_to_clean): @@ -58,26 +64,33 @@ def handle_upload_file(request, element_name, mimetype, tag_name): opencast_filename = None # tags = "" # not use actually id_media = get_id_media(request) - if request.POST.get("flavor") and request.POST.get("flavor") != "": + if request.POST.get("flavor", "") != "": type_name = request.POST.get("flavor") - media_package_dir = os.path.join( - settings.MEDIA_ROOT, OPENCAST_FILES_DIR, "%s" % id_media - ) + media_package_dir = os.path.join(MEDIA_ROOT, OPENCAST_FILES_DIR, "%s" % id_media) media_package_content, media_package_file = get_media_package_content( media_package_dir, id_media ) if element_name != "attachment": + file = "" + filename = "" + + if "BODY" in request.FILES: + file = request.FILES["BODY"] + filename = file.name + + if request.FILES.getlist("file"): + file = request.FILES.getlist("file")[0] + filename = file.name + if element_name == "track": - opencast_filename, ext = os.path.splitext(request.FILES["BODY"].name) + opencast_filename, ext = os.path.splitext(filename) filename = "%s%s" % (type_name.replace("/", "_").replace(" ", ""), ext) - elif element_name == "catalog": - filename = request.FILES["BODY"].name opencastMediaFile = os.path.join(media_package_dir, filename) with open(opencastMediaFile, "wb+") as destination: - for chunk in request.FILES["BODY"].chunks(): + for chunk in file.chunks(): destination.write(chunk) url_text = "%(http)s://%(host)s%(media)sopencast-files/%(id_media)s/%(fn)s" % { @@ -109,7 +122,7 @@ def handle_upload_file(request, element_name, mimetype, tag_name): def get_id_media(request): """Extract and returns id_media from the mediaPackage in the request.""" if ( - request.POST.get("mediaPackage") != "" + request.POST.get("mediaPackage", "") != "" and request.POST.get("mediaPackage") != "{}" ): mediaPackage = request.POST.get("mediaPackage") @@ -174,3 +187,92 @@ def create_xml_element( element.appendChild(live) return element + + +def create_digest_auth_response(request): + """ + Create a HttpResponse: + 403 if the sender's ip is defined in the Recorders. + 401 otherwise with realm and nonce (being the salt of the Recorder whose ip matches the sender's ip). + """ + client_ip = request.META.get("REMOTE_ADDR", "none") + recorder = Recorder.objects.filter(address_ip=client_ip).first() + if recorder is None: + return HttpResponse(status=403) + h_key = "WWW-Authenticate" + header = {h_key: 'Digest realm="Opencast", nonce="salt"'} + header[h_key] = header[h_key].replace("salt", recorder.salt) + return HttpResponse(headers=header, status=401) + + +def digest_is_valid(request) -> bool: + """Check if the digest hash is valid.""" + auth_headers = get_auth_headers_as_dict(request) + + if not auth_headers: + # print("no authentication in Headers") + return False + + if ( + "username" not in auth_headers + and "realm" not in auth_headers + and "uri" not in auth_headers + and "response" not in auth_headers + ): + # print("missing data to compute hash") + return False + + client_ip = request.META.get("REMOTE_ADDR", "none") + recorder = Recorder.objects.filter(address_ip=client_ip).first() + if recorder is None: + print("no Recorder found with Ip: " + client_ip) + return False + if recorder.credentials_login != auth_headers["username"]: + print( + "Recorder ip '" + + recorder.address_ip + + "' and login '" + + auth_headers["username"] + + "' mismatch" + ) + return False + + # print("Recorder: " + str(recorder)) + # print(auth_headers['realm'] + " - " + request.method + " - " + auth_headers['uri']) + computed_hash = compute_digest_recorder( + recorder, auth_headers["realm"], request.method, auth_headers["uri"] + ) + # print(computed_hash + " vs " + auth_headers['response']) + return computed_hash == auth_headers["response"] + + +def get_auth_headers_as_dict(request) -> dict: + """Return a dict with Authorization headers as a dict.""" + result = {} + if "Authorization" in request.headers: + # Regex pattern that match key-value pairs + pattern = r'(\w+)="([^"]+)"' + + matches = re.findall(pattern, request.headers["Authorization"]) + result = {key: value for key, value in matches} + return result + + +def compute_digest_recorder(recorder, realm, method, uri): + """Call method compute_digest() with recorder data.""" + return compute_digest( + recorder.credentials_login, + realm, + recorder.credentials_password, + method, + uri, + recorder.salt, + ) + + +def compute_digest(user, realm, passwd, method, uri, nonce): + """Compute a digest hash with md5 and no qop.""" + ha1 = hashlib.md5(f"{user}:{realm}:{passwd}".encode("utf-8")).hexdigest() + ha2 = hashlib.md5(f"{method}:{uri}".encode("utf-8")).hexdigest() + response = hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode("utf-8")).hexdigest() + return response diff --git a/pod/recorder/views.py b/pod/recorder/views.py index 174621b7b4..7fd1b07214 100644 --- a/pod/recorder/views.py +++ b/pod/recorder/views.py @@ -1,57 +1,60 @@ # -*- coding: utf-8 -*- """Esup-pod recorder views.""" +import hashlib +import logging import os -import datetime -import uuid import re -import bleach +import uuid +from datetime import datetime, timedelta # import urllib from urllib.parse import unquote +from xml.dom import minidom -from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage -from django.shortcuts import render +import bleach from django.conf import settings -from django.urls import reverse -from django.core.exceptions import PermissionDenied - -# from django.core.exceptions import SuspiciousOperation -from django.shortcuts import redirect +from django.contrib import messages from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import user_passes_test -from django.views.decorators.csrf import csrf_protect -from django.utils.translation import ugettext_lazy as _ -from django.contrib.sites.shortcuts import get_current_site -from pod.recorder.models import Recorder, Recording, RecordingFileTreatment -from .forms import RecordingForm, RecordingFileTreatmentDeleteForm -from .models import __REVATSO__ -from django.contrib import messages -import hashlib -from django.http import HttpResponseRedirect, JsonResponse -from django.http import HttpResponse, HttpResponseBadRequest -from django.template.loader import render_to_string -from django.template.defaultfilters import truncatechars -from django.core.mail import EmailMultiAlternatives - - +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 ObjectDoesNotExist +from django.core.exceptions import PermissionDenied +from django.core.mail import EmailMultiAlternatives +from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage +from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponseRedirect, JsonResponse # import urllib.parse from django.shortcuts import get_object_or_404 -from pod.main.views import in_maintenance, TEMPLATE_VISIBLE_SETTINGS + +# from django.core.exceptions import SuspiciousOperation +from django.shortcuts import redirect +from django.shortcuts import render +from django.template.defaultfilters import truncatechars +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ from django.views.decorators.csrf import csrf_exempt -from xml.dom import minidom +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.http import require_http_methods +from pod.main.views import in_maintenance, TEMPLATE_VISIBLE_SETTINGS +from pod.recorder.models import Recorder, Recording, RecordingFileTreatment +from .forms import RecordingForm, RecordingFileTreatmentDeleteForm +from .models import __REVATSO__ from .utils import ( get_id_media, handle_upload_file, create_xml_element, get_media_package_content, + digest_is_valid, + create_digest_auth_response, ) DEFAULT_RECORDER_PATH = getattr(settings, "DEFAULT_RECORDER_PATH", "/data/ftp-pod/ftp/") +DEFAULT_RECORDER_USER_ID = getattr(settings, "DEFAULT_RECORDER_USER_ID", 1) # USE_CAS = getattr(settings, "USE_CAS", False) # USE_SHIB = getattr(settings, "USE_SHIB", False) @@ -76,13 +79,24 @@ MEDIA_URL = getattr(settings, "MEDIA_URL", "/media/") +logger = logging.getLogger("pod.recorder.view") + + +def check_recorder(recorder_id, request): + """Check if a Recorder with this id exist. + Args: + recorder_id (int): The recorder id. + request (WSGIRequest): The request. -def check_recorder(recorder, request): - if recorder is None: + Returns: + The Recorder if exists or PermissionDenied otherwise. + + """ + if recorder_id is None: messages.add_message(request, messages.ERROR, _("Recorder should be indicated.")) raise PermissionDenied try: - recorder = Recorder.objects.get(id=recorder) + recorder = Recorder.objects.get(id=recorder_id) except ObjectDoesNotExist: messages.add_message(request, messages.ERROR, _("Recorder not found.")) raise PermissionDenied @@ -90,6 +104,7 @@ def check_recorder(recorder, request): def case_delete(form, request): + """Delete a file.""" file = form.cleaned_data["source_file"] try: if os.path.exists(file): @@ -103,6 +118,8 @@ def case_delete(form, request): def fetch_user(request, form): + """Return the user from the request.""" + if request.POST.get("user") and request.POST.get("user") != "": return form.cleaned_data["user"] else: @@ -205,6 +222,8 @@ def reformat_url_if_use_cas_or_shib(request, link_url): def recorder_notify(request): + """Notify the recorder.""" + # Used by URL like https://pod.univ.fr/recorder_notify/?recordingPlace # =192_168_1_10&mediapath=file.zip&key=77fac92a3f06d50228116898187e50e5 mediapath = request.GET.get("mediapath") or "" @@ -282,6 +301,8 @@ def recorder_notify(request): @login_required(redirect_field_name="referrer") @staff_member_required(redirect_field_name="referrer") def claim_record(request): + """Claim a record.""" + if in_maintenance(): return redirect(reverse("maintenance")) site = get_current_site(request) @@ -332,6 +353,7 @@ def claim_record(request): redirect_field_name="referrer", ) def delete_record(request, id=None): + """Delete a record.""" record = get_object_or_404(RecordingFileTreatment, id=id) form = RecordingFileTreatmentDeleteForm() @@ -365,10 +387,48 @@ def delete_record(request, id=None): ) -# OPENCAST VIEWS -@login_required(redirect_field_name="referrer") -def studio_pod(request): +def studio_toml(request, studio_url): + """Render a settings.toml configuration file for Opencast Studio.""" + # OpenCast Studio configuration + # See https://github.com/elan-ev/opencast-studio/blob/master/CONFIGURATION.md + # Add parameter: the pod studio URL + studio_url = request.build_absolute_uri( + reverse( + "recorder:studio_pod", + ) + ) + dashboard_url = request.build_absolute_uri( + reverse( + "video:dashboard", + ) + ) + # force https for developpement server + studio_url = studio_url.replace("http://", "https://") + dashboard_url = dashboard_url.replace("http://", "https://") + content_text = """ + [opencast] + serverUrl = "%(serverUrl)s" + loginProvided = true + [upload] + presenterField = 'hidden' + seriesField = 'hidden' + [return] + target = "%(target)s" + label = "%(label)s" + [theme] + """ + content_text = content_text % { + "serverUrl": studio_url, + "target": dashboard_url, + "label": "mes videos", + } + return HttpResponse(content_text, content_type="text/plain") + + +# INNER METHODS +def open_studio_pod(request): """Render the Opencast studio view in Esup-Pod.""" + if in_maintenance(): return redirect(reverse("maintenance")) if __REVATSO__ and request.user.is_staff is False: @@ -398,10 +458,9 @@ def studio_pod(request): ) -@csrf_exempt -@login_required(redirect_field_name="referrer") -def presenter_post(request): +def open_presenter_post(request): """Check if the value for `presenter` is valid.""" + if ( request.POST and request.POST.get("presenter") @@ -413,9 +472,9 @@ def presenter_post(request): return HttpResponseBadRequest() -@login_required(redirect_field_name="referrer") -def studio_static(request, file): +def open_studio_static(request, file): """Redirect to all static files inside Opencast studio static subfolder.""" + extension = file.split(".")[-1] if extension == "js": path_file = os.path.join( @@ -428,75 +487,22 @@ def studio_static(request, file): return HttpResponseRedirect("/static/opencast/studio/static/%s" % file) -@login_required(redirect_field_name="referrer") -def studio_root_file(request, file): - """Redirect to root static files of Opencast studio folder.""" - extension = file.split(".")[-1] - if extension == "js": - path_file = os.path.join( - settings.BASE_DIR, "custom", "static", "opencast", "studio/%s" % file - ) - f = open(path_file, "r") - content_file = f.read() - content_file = content_file.replace("Opencast", "Pod") - return HttpResponse(content_file, content_type="application/javascript") - return HttpResponseRedirect("/static/opencast/studio/%s" % file) - - -@login_required(redirect_field_name="referrer") -def settings_toml(request): - """Render a settings.toml configuration file for Opencast Studio.""" - # OpenCast Studio configuration - # See https://github.com/elan-ev/opencast-studio/blob/master/CONFIGURATION.md - # Add parameter: the pod studio URL - studio_url = request.build_absolute_uri( - reverse( - "recorder:studio_pod", - ) - ) - dashboard_url = request.build_absolute_uri( - reverse( - "video:dashboard", - ) - ) - # force https for developpement server - studio_url = studio_url.replace("http://", "https://") - dashboard_url = dashboard_url.replace("http://", "https://") - content_text = """ - [opencast] - serverUrl = "%(serverUrl)s" - loginProvided = true - [upload] - presenterField = 'hidden' - seriesField = 'hidden' - [return] - target = "%(target)s" - label = "%(label)s" - [theme] - """ - content_text = content_text % { - "serverUrl": studio_url, - "target": dashboard_url, - "label": "mes videos", - } - return HttpResponse(content_text, content_type="text/plain") - +def open_info_me_json(request): + """Render an info/me.json file for current user roles in Opencast Studio.""" -@login_required(redirect_field_name="referrer") -def info_me_json(request): - """Render a info/me.json file for current user roles in Opencast Studio.""" # Providing a user with ROLE_STUDIO should grant all necessary rights. # See https://github.com/elan-ev/opencast-studio/blob/master/README.md return render(request, "studio/me.json", {}, content_type="application/json") -@login_required(redirect_field_name="referrer") -def ingest_createMediaPackage(request): +def open_ingest_createMediaPackage(request): + """Create and return a mediaPacakge xml file.""" + # URI createMediaPackage useful for OpenCast Studio # Necessary id. Example format: a3d9e9f3-66d0-403b-a775-acb3f79196d4 id_media = uuid.uuid4() # Necessary start date. Example format: 2021-12-08T08:52:28Z - start = datetime.datetime.strftime(datetime.datetime.now(), "%Y-%m-%dT%H:%M:%S%zZ") + start = datetime.now().strftime("%Y-%m-%dT%H:%M:%S%zZ") media_package_dir = os.path.join( settings.MEDIA_ROOT, OPENCAST_FILES_DIR, "%s" % id_media ) @@ -520,44 +526,47 @@ def ingest_createMediaPackage(request): return HttpResponse(media_package_content.toxml(), content_type="application/xml") -@login_required(redirect_field_name="referrer") -@csrf_exempt -def ingest_addDCCatalog(request): - # URI addDCCatalog useful for OpenCast Studio +def open_ingest_addDCCatalog(request): + """URI addDCCatalog useful for OpenCast Studio.""" # Form management with 3 parameters: mediaPackage, dublin_core, flavor # For Pod, management of dublin_core is useless if ( request.POST.get("mediaPackage") and request.POST.get("flavor") - and request.POST.get("dublinCore") + and (request.POST.get("dublinCore") or request.FILES.getlist("dublinCore")) ): typeCatalog = "dublincore/episode" - # Id catalog. Example format: 798017b1-2c45-42b1-85b0-41ce804fa527 - # idCatalog = uuid.uuid4() - # Id media package - id_media = "" - # dublinCore - dublin_core = "" id_media = get_id_media(request) - if request.POST.get("flavor") and request.POST.get("flavor") != "": - typeCatalog = request.POST.get("flavor") - if request.POST.get("dublinCore") and request.POST.get("dublinCore") != "": - dublin_core = request.POST.get("dublinCore") + # Create directory to store the data media_package_dir = os.path.join( settings.MEDIA_ROOT, OPENCAST_FILES_DIR, "%s" % id_media ) - # create directory to store the dublincore file. os.makedirs(media_package_dir, exist_ok=True) - # store the dublin core file - dublin_core_file = os.path.join(media_package_dir, "dublincore.xml") - with open(dublin_core_file, "w+") as f: - f.write(unquote(dublin_core)) media_package_content, media_package_file = get_media_package_content( media_package_dir, id_media ) + # Create the dublincore file. + dublincore_file = os.path.join(media_package_dir, "dublincore.xml") + + # Get and store the content of dublincore file. + if request.POST.get("dublinCore") and request.POST.get("dublinCore") != "": + dublin_core_file = request.POST.get("dublinCore") + with open(dublincore_file, "w+") as f: + f.write(unquote(dublin_core_file)) + + elif request.FILES.getlist("dublinCore"): + file = request.FILES.getlist("dublinCore")[0] + with open(dublincore_file, "wb+") as destination: + for chunk in file.chunks(): + destination.write(chunk) + + # Create the xml file with the same name as the folder with typeCatalog access url + if request.POST.get("flavor") and request.POST.get("flavor") != "": + typeCatalog = request.POST.get("flavor") + dc_url = str( "%(http)s://%(host)s%(media)sopencast-files/%(id_media)s/dublincore.xml" % { @@ -582,55 +591,48 @@ def ingest_addDCCatalog(request): return HttpResponseBadRequest() -@csrf_exempt -@login_required(redirect_field_name="referrer") -def ingest_addAttachment(request): +def open_ingest_addAttachment(request): """URI addAttachment useful for OpenCast Studio.""" # Form management with 3 parameters: mediaPackage, flavor, BODY (acl.xml file) if ( request.POST.get("mediaPackage") and request.POST.get("flavor") - and request.FILES.get("BODY") + and (request.FILES.get("BODY") or request.FILES.getlist("file")) ): return handle_upload_file(request, "attachment", "text/xml", "attachments") return HttpResponseBadRequest() -@csrf_exempt -@login_required(redirect_field_name="referrer") -def ingest_addTrack(request): +def open_ingest_addTrack(request): """URI addTrack useful for OpenCast Studio.""" # Form management with 4 parameters: mediaPackage, flavor, tags, BODY (video file) if ( request.POST.get("mediaPackage") and request.POST.get("flavor") + and (request.FILES.get("BODY") or request.FILES.getlist("file")) # and request.POST.get("tags") # unused tags - and request.FILES.get("BODY") ): return handle_upload_file(request, "track", "video/webm", "media") return HttpResponseBadRequest() -@csrf_exempt -@login_required(redirect_field_name="referrer") -def ingest_addCatalog(request): +def open_ingest_addCatalog(request): """URI ingest useful for OpenCast Studio (when cutting video).""" # Form management with 3 parameter: flavor, mediaPackage, BODY(smil file) if ( request.POST.get("mediaPackage") and request.POST.get("flavor") - and request.FILES.get("BODY") + and (request.FILES.get("BODY") or request.FILES.getlist("file")) ): return handle_upload_file(request, "catalog", "text/xml", "metadata") return HttpResponseBadRequest() -@csrf_exempt -@login_required(redirect_field_name="referrer") -def ingest_ingest(request): +def open_ingest_ingest(request): """URI ingest useful for OpenCast Studio.""" # Form management with 1 parameter: mediaPackage # Management of the mediaPackage (XML) + if request.POST.get("mediaPackage"): id_media = get_id_media(request) media_package_dir = os.path.join( @@ -639,14 +641,21 @@ def ingest_ingest(request): media_package_content, media_package_file = get_media_package_content( media_package_dir, id_media ) + # Create the recording # Search for the recorder corresponding to the Studio recorder = Recorder.objects.filter( recording_type="studio", sites=get_current_site(None) ).first() + if recorder: + if not request.user.is_anonymous: + req_user = request.user + else: + req_user = User.objects.get(id=DEFAULT_RECORDER_USER_ID) + recording = Recording.objects.create( - user=request.user, + user=req_user, title=id_media, type="studio", # Source file corresponds to Pod XML file @@ -663,3 +672,355 @@ def ingest_ingest(request): return HttpResponse(media_package_content.toxml(), content_type="application/xml") return HttpResponseBadRequest() + + +# OPENCAST VIEWS WITH LOGIN MANDATORY +@login_required(redirect_field_name="referrer") +def studio_pod(request): + """Call open_studio_pod() if user is logged in.""" + return open_studio_pod(request) + + +@csrf_exempt +@login_required(redirect_field_name="referrer") +def presenter_post(request): + """Call open_presenter_post() if user is logged in.""" + return open_presenter_post(request) + + +@login_required(redirect_field_name="referrer") +def studio_static(request, file): + """Call open_studio_static() if user is logged in.""" + return open_studio_static(request, file) + + +@login_required(redirect_field_name="referrer") +def studio_root_file(request, file): + """Redirect to root static files of Opencast studio folder.""" + extension = file.split(".")[-1] + if extension == "js": + path_file = os.path.join( + settings.BASE_DIR, "custom", "static", "opencast", "studio/%s" % file + ) + f = open(path_file, "r") + content_file = f.read() + content_file = content_file.replace("Opencast", "Pod") + return HttpResponse(content_file, content_type="application/javascript") + return HttpResponseRedirect("/static/opencast/studio/%s" % file) + + +@login_required(redirect_field_name="referrer") +def settings_toml(request): + """Render a settings.toml configuration file for Opencast Studio.""" + studio_url = request.build_absolute_uri( + reverse( + "recorder:studio_pod", + ) + ) + return studio_toml(request, studio_url) + + +@login_required(redirect_field_name="referrer") +def info_me_json(request): + """Call open_info_me_json() if user is logged in.""" + return open_info_me_json(request) + + +@login_required(redirect_field_name="referrer") +def ingest_createMediaPackage(request): + """Call open_ingest_createMediaPackage() if user is logged in.""" + return open_ingest_createMediaPackage(request) + + +@login_required(redirect_field_name="referrer") +@csrf_exempt +def ingest_addDCCatalog(request): + """Call open_ingest_addDCCatalog() if user is logged in.""" + return open_ingest_addDCCatalog(request) + + +@csrf_exempt +@login_required(redirect_field_name="referrer") +def ingest_addAttachment(request): + """Call open_ingest_addAttachment() if user is logged in.""" + return open_ingest_addAttachment(request) + + +@csrf_exempt +@login_required(redirect_field_name="referrer") +def ingest_addTrack(request): + """Call open_ingest_addTrack() if user is logged in.""" + return open_ingest_addTrack(request) + + +@csrf_exempt +@login_required(redirect_field_name="referrer") +def ingest_addCatalog(request): + """Call open_ingest_addCatalog() if user is logged in.""" + return open_ingest_addCatalog(request) + + +@csrf_exempt +@login_required(redirect_field_name="referrer") +def ingest_ingest(request): + """Call open_ingest_ingest() if user is logged in.""" + return open_ingest_ingest(request) + + +# OPENCAST VIEWS WITH Almost DIGEST AUTH (FOR EXTERNAL API CALL) +@csrf_exempt +@require_http_methods(["POST"]) +def digest_presenter_post(request): + """Call open_presenter_post() if user credentials are valid.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + return open_presenter_post(request) + + +@require_http_methods(["GET"]) +def digest_studio_static(request, file): + """Call open_studio_static() if user credentials are valid.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + return open_studio_static(request, file) + + +@require_http_methods(["GET"]) +def digest_settings_toml(request): + """Render a settings.toml configuration file for Opencast Studio.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + studio_url = request.build_absolute_uri( + reverse( + "recorder_digest:digest_studio_pod", + ) + ) + + return studio_toml(request, studio_url) + + +@require_http_methods(["GET"]) +def digest_info_me_json(request): + """Call open_info_me_json() if user credentials are valid.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + return open_info_me_json(request) + + +@require_http_methods(["GET"]) +def digest_ingest_createMediaPackage(request): + """Call open_ingest_createMediaPackage() if user credentials are valid.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + return open_ingest_createMediaPackage(request) + + +@csrf_exempt +@require_http_methods(["POST"]) +def digest_ingest_addDCCatalog(request): + """Call open_ingest_addDCCatalog() if user credentials are valid.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + return open_ingest_addDCCatalog(request) + + +@csrf_exempt +@require_http_methods(["POST"]) +def digest_ingest_addAttachment(request): + """Call open_ingest_addAttachment() if user credentials are valid.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + return open_ingest_addAttachment(request) + + +@csrf_exempt +@require_http_methods(["POST"]) +def digest_ingest_addTrack(request): + """Call open_ingest_addTrack() if user credentials are valid.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + return open_ingest_addTrack(request) + + +@csrf_exempt +@require_http_methods(["POST"]) +def digest_ingest_addCatalog(request): + """Call open_ingest_addCatalog() if user credentials are valid.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + return open_ingest_addCatalog(request) + + +@csrf_exempt +@require_http_methods(["POST"]) +def digest_ingest_ingest(request): + """Call open_ingest_ingest() if user credentials are valid.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + return open_ingest_ingest(request) + + +@require_http_methods(["GET"]) +def digest_hosts_json(request): + """URI hosts_json useful for OpenCast Studio.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + host = ( + "https://%s" % request.get_host() + if (request.is_secure()) + else "http://%s" % request.get_host() + ) + server_ip = request.META.get( + "HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "") + ) + server_ip = server_ip.split(",")[0] if server_ip else None + + # cf https://stable.opencast.org/docs.html?path=/services & https://stable.opencast.org/services/hosts.json + return JsonResponse( + { + "hosts": { + "host": { + "base_url": host, + "address": server_ip, + "node_name": "AllInOne", + "memory": 2082471936, + "cores": 2, + "max_load": 2, + "online": True, + "active": True, + "maintenance": False, + } + }, + }, + status=200, + ) + + +@csrf_exempt +@require_http_methods(["POST"]) +def digest_capture_admin(request, name): + """URI capture_admin useful for OpenCast Studio.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + known_states = [ + "idle", + "shutting_down", + "capturing", + "uploading", + "unknown", + "offline", + "error", + ] + if request.POST.get("state", "") not in known_states: + return HttpResponseBadRequest() + + return HttpResponse(name + " set to " + request.POST.get("state")) + + +@csrf_exempt +@require_http_methods(["POST"]) +def digest_capture_admin_configuration(request, name): + """URI capture_admin_configuration useful for OpenCast Studio.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + xml_to_return = '' + + # attention clés / valeurs identiques à ce qui est envoyé + if request.content_type == "multipart/form-data": + dom = minidom.parseString(request.POST.get("configuration")) + entries = dom.getElementsByTagName("entry") + + xml_to_return += ( + '' + ) + xml_to_return += "" + for entry in entries: + key = entry.getAttribute("key") + value = entry.firstChild.nodeValue if entry.firstChild else None + if key and value: + xml_to_return += f'{value}' + xml_to_return += "" + + return HttpResponse(xml_to_return, content_type="application/xml") + + +@require_http_methods(["GET"]) +def digest_admin_ng_series(request): + """URI admin_ng_series useful for OpenCast Studio.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + # Example format: 2021-12-08T08:52:28Z + one_minute_ago = datetime.now() + timedelta(minutes=-1) + creation_date = one_minute_ago.strftime("%Y-%m-%dT%H:%M:%S%zZ") + + return JsonResponse( + { + "total": 1, + "offset": 0, + "count": 1, + "limit": 100, + "results": [ + { + "createdBy": "admin", + "organizers": [], + "id": "ID-blender-foundation", + "contributors": [], + "creation_date": creation_date, + "title": "admin", + } + ], + }, + status=200, + ) + + +@require_http_methods(["GET"]) +def digest_available(request): + """URI available useful for OpenCast Studio.""" + if not digest_is_valid(request): + return create_digest_auth_response(request) + + host = ( + "https://%s" % request.get_host() + if (request.is_secure()) + else "http://%s" % request.get_host() + ) + + # Example format: 2021-12-08T08:52:28Z + yesterday = datetime.now() + timedelta(days=-1) + online = yesterday.strftime("%Y-%m-%dT%H:%M:%S%zZ") + return JsonResponse( + { + "services": { + "service": { + "type": "org.opencastproject.capture.admin", + "host": host, + "path": "open/studio/capture-admin", + "active": True, + "online": True, + "maintenance": False, + "jobproducer": False, + "onlinefrom": online, + "service_state": "NORMAL", + "state_changed": online, + "error_state_trigger": 0, + "warning_state_trigger": 0, + } + } + }, + status=200, + ) diff --git a/pod/urls.py b/pod/urls.py index a168d96273..7a97acf666 100644 --- a/pod/urls.py +++ b/pod/urls.py @@ -142,6 +142,7 @@ if USE_OPENCAST_STUDIO: urlpatterns += [ url(r"^studio/", include("pod.recorder.studio_urls")), + url(r"^digest/studio/", include("pod.recorder.studio_urls_digest")), ] # PODFILE