From 8f66ae9f646668e9e89ae820baf23e9b08a2c361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Bonavent?= <56730254+LoicBonavent@users.noreply.github.com> Date: Fri, 12 Apr 2024 13:32:50 +0200 Subject: [PATCH] [DONE] Feature: Webinar management in the meetings module (#1092) * Delete useless templates (replaced by pod/live/templates/meeting/meeting_live_form.html) * Delete useless code for webinar chat and monitoring * Manages the sending of messages for live webinars * Add webinar parameters: USE_MEETING_WEBINAR, MEETING_WEBINAR_SIPMEDIAGW_URL, MEETING_WEBINAR_SIPMEDIAGW_TOKEN, MEETING_WEBINAR_FIELDS, MEETING_WEBINAR_AFFILIATION, MEETING_WEBINAR_GROUP_ADMIN * Manage USE_MEETING_WEBINAR parameter * Add configuration needed to test webinars * Add webinars fields, LivestreamAdmin and IngesterAdmin classes * Manage webinars fields and rules * Add webinars fields, Livestream and Ingester classes * Add 2 CSS classes for meeting card * Add actions buttons to manage a webinar (restart a live, stop a live, stop webinar (meeting and live)) * Manage webinars (no reccurrence available, guest policy = Always accept...) * Manage webinars, add webinar actions buttons, display correction * Add some webinars tests * Add URLs for manage webinars * Code reordering * Manages webinars views and refactor some code * Add filter aside for meeting * Add webinar utils tests * Management of webinars for the Meeting module and SIPMediaGW API * Utils to manage webinars for Meeting module * Manages the sending of messages for live webinars (eplace pod/live/templates/bbb/bbb_form.html) * Add REST meeting views * Add REST meeting views * Add needed translations to manage webinars * Modify translations to manage webinars * Add REST meeting views * Use d-none class * Manage spaces and label for textarea * Add a line at the end of the file * Modify translations * Changes in Pydoc * Remove useless spaces * Remove useless spaces * Correct show_chat init * [DONE] Add a submit button on the add video page (#1088) * Add a submit button on the add video page, to let user choose if he want to upload or not, and have more time to choose transcription lang + Add a required checkbox for legal notice * undo typo in previous commit * Replace deprecated `docker-compose` (v1) by `docker compose` (v2) + php code formatting * Compiled .mo files * Minor corrections * add required star on legal notice checkbox * Auto-update configuration files * Close p before ol * Remove show_chat parameter * Remove show_chat parameter * Modify translations (replace ingester by live gateway) to manage webinars * Remove show_chat parameter * Add LiveGateway route * Remove show_chat parameter and replace ingester by live gateway * Add LiveGateway route * Remove show_chat parameter * Replace ingester by live gateway to test webinars * Remove show_chat parameter and replace ingester by live gateway * Merge translations * Merge --------- Co-authored-by: Olivier Bado-Faustin Co-authored-by: github-actions --- pod/live/templates/bbb/bbb_form.html | 23 - pod/live/templates/live/direct.html | 41 -- pod/live/templates/live/event-script.html | 122 ++--- pod/live/templates/live/event.html | 4 +- .../templates/meeting/meeting_live_form.html | 27 + pod/live/views.py | 30 +- pod/locale/fr/LC_MESSAGES/django.mo | Bin 187535 -> 197876 bytes pod/locale/fr/LC_MESSAGES/django.po | 500 +++++++++++++++--- pod/locale/fr/LC_MESSAGES/djangojs.mo | Bin 19745 -> 20666 bytes pod/locale/fr/LC_MESSAGES/djangojs.po | 114 ++-- pod/locale/nl/LC_MESSAGES/django.po | 363 +++++++++++-- pod/locale/nl/LC_MESSAGES/djangojs.po | 81 ++- pod/main/configuration.json | 87 +++ pod/main/context_processors.py | 3 + pod/main/rest_router.py | 9 + pod/main/test_settings.py | 6 + pod/meeting/admin.py | 41 +- pod/meeting/forms.py | 98 +++- pod/meeting/models.py | 143 ++++- pod/meeting/rest_views.py | 79 +++ pod/meeting/static/css/meeting.css | 11 +- pod/meeting/static/js/my_meetings.js | 47 +- .../templates/meeting/add_or_edit.html | 51 +- .../meeting/filter_aside_meeting.html | 53 ++ .../templates/meeting/meeting_card.html | 54 +- pod/meeting/tests/test_utils.py | 106 ++++ pod/meeting/tests/test_views.py | 226 ++++++++ pod/meeting/urls.py | 12 + pod/meeting/utils.py | 6 +- pod/meeting/views.py | 453 ++++++++++++---- pod/meeting/webinar.py | 417 +++++++++++++++ pod/meeting/webinar_utils.py | 210 ++++++++ 32 files changed, 2952 insertions(+), 465 deletions(-) delete mode 100644 pod/live/templates/bbb/bbb_form.html create mode 100644 pod/live/templates/meeting/meeting_live_form.html create mode 100644 pod/meeting/rest_views.py create mode 100644 pod/meeting/templates/meeting/filter_aside_meeting.html create mode 100644 pod/meeting/tests/test_utils.py create mode 100644 pod/meeting/webinar.py create mode 100644 pod/meeting/webinar_utils.py diff --git a/pod/live/templates/bbb/bbb_form.html b/pod/live/templates/bbb/bbb_form.html deleted file mode 100644 index c85e1f3c05..0000000000 --- a/pod/live/templates/bbb/bbb_form.html +++ /dev/null @@ -1,23 +0,0 @@ -{% load i18n %} - -
-
- {% csrf_token %} -
-

{% trans 'Send message' %}

- {% if user.is_authenticated %} - {% trans 'You can send a message (100 characters maximum) to the BigBlueButton session. It will be displayed within 15 to 30 seconds on the live video.' %} - - - {% else %} - {% trans 'You must be authenticated to send a message.' %} - {% endif %} -
-
-
- {% if user.is_authenticated %} - - {% endif %} -
-
\ No newline at end of file diff --git a/pod/live/templates/live/direct.html b/pod/live/templates/live/direct.html index 1aaaa50958..108fd5401f 100644 --- a/pod/live/templates/live/direct.html +++ b/pod/live/templates/live/direct.html @@ -259,46 +259,5 @@

if ($("#divvideoplayer")) { $("#divvideoplayer").css("display", "block"); } }) -{% if display_chat %} - -{% endif %} {% endblock more_script %} diff --git a/pod/live/templates/live/event-script.html b/pod/live/templates/live/event-script.html index cfd9e00a5c..e1f16f5966 100644 --- a/pod/live/templates/live/event-script.html +++ b/pod/live/templates/live/event-script.html @@ -684,69 +684,69 @@ {% endif %} } - // BBB message sending - {% if display_chat %} - function displayReturnMessage(level, returnCode) { - let toReturn = ""; - let returnElement = document.getElementById("message_return"); - if (level === "info") { - returnElement.classList.add('alert'); - returnElement.classList.add('alert-info'); - } else { - returnElement.classList.add('alert'); - returnElement.classList.add('alert-warning'); - } - if (returnCode === "message_sent") { - toReturn = "{% trans 'Message sent' %}"; - } - if (returnCode === "error_no_broadcaster_found") { - toReturn = "{% trans 'Message not sent: no broadcaster found' %}"; - } - if (returnCode === "error_no_connection") { - toReturn = "{% trans 'Message not sent: connection problem (REDIS)' %}"; - } - - returnElement.innerHTML = toReturn; - returnElement.style.display = "block"; - setTimeout(function() { - returnElement.style.display = "none"; - }, 3000) - } + // Webinar message sending + {% if enable_chat %} + /** + * Display if message was sent, or not, to the server. + */ + function displayReturnMessage(level, returnCode) { + let toReturn = ""; + let returnElement = document.getElementById("message_return"); + if (level === "info") { + returnElement.classList.add('alert', 'alert-info'); + } else { + returnElement.classList.add('alert', 'alert-danger'); + } + if (returnCode === "message_sent") { + toReturn = "{% trans 'Message sent' %}"; + } + if (returnCode === "error") { + toReturn = "{% trans 'Message not sent' %}"; + } - function sendBBBMessage(e) { - e.preventDefault(); - let message = document.getElementById("message").value; - fetch("{% url 'bbb:live_publish_chat' id=event.broadcaster.id %}", { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - "X-CSRFToken": '{{ csrf_token }}' - }, - body: JSON.stringify({"message": message}), - }).then((response) => { - if (response.ok) - return response.json(); - else - return Promise.reject(response); - }).then((data) => { - document.getElementById('live_bbb_chat_form').reset(); - if (data.is_sent) { - // message_sent - displayReturnMessage("info", data.message_return); - } else { - // error_no_broadcaster_found: Message not sent: no broadcaster found - // error_no_connection: Message not sent: no connection to REDIS - displayReturnMessage("error", data.message_return); - } - }).catch((error) => { - console.log("{% trans 'Error calling' %} 'sendBBBMessage' " + error); - }); - } + returnElement.innerHTML = toReturn; + returnElement.classList.remove("d-none"); + setTimeout(function() { + returnElement.classList.add("d-none"); + }, 3000) + } + + /** + * Send a message to a webinar live. + * */ + function sendWebinarMessage(e) { + e.preventDefault(); + let message = document.getElementById("message").value; + fetch("{% url 'meeting:live_publish_chat' id=event.id %}", { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + "X-CSRFToken": '{{ csrf_token }}' + }, + body: JSON.stringify({"message": message}), + }).then((response) => { + if (response.ok) + return response.json(); + else + return Promise.reject(response); + }).then((data) => { + document.getElementById('live_meeting_chat_form').reset(); + console.info(data); + if (data.res) { + // Message_sent + displayReturnMessage("info", data.message_return); + } else { + displayReturnMessage("error", data.message_return); + } + }).catch((error) => { + console.log("{% trans 'Error calling' %} 'sendWebinarMessage' " + error); + }); + } - document.getElementById('bbb-send-message').onclick = function ($this) { - sendBBBMessage($this); - }; + document.getElementById('webinar-send-message').onclick = function ($this) { + sendWebinarMessage($this); + }; {% endif %} diff --git a/pod/live/templates/live/event.html b/pod/live/templates/live/event.html index 1695b6f206..46bb470eb5 100644 --- a/pod/live/templates/live/event.html +++ b/pod/live/templates/live/event.html @@ -123,8 +123,8 @@

    diff --git a/pod/live/templates/meeting/meeting_live_form.html b/pod/live/templates/meeting/meeting_live_form.html new file mode 100644 index 0000000000..0356478cb1 --- /dev/null +++ b/pod/live/templates/meeting/meeting_live_form.html @@ -0,0 +1,27 @@ +{% load i18n %} + +
    +
    +

    {% trans 'Send message' %}

    + {% if user.is_authenticated %} +
    + {% csrf_token %} +
    +

    + {% trans 'You can send a message to the webinar presenters (100 characters maximum).' %} + {% trans 'It will be displayed after 10 to 30 seconds on the live stream.' %} +

    + + +
    + +
    + +
    +
    + {% else %} +
    {% trans 'You must be authenticated to send a message.' %}
    + {% endif %} +
    +
    +
    diff --git a/pod/live/views.py b/pod/live/views.py index 683832ad5b..12a66ae2c2 100644 --- a/pod/live/views.py +++ b/pod/live/views.py @@ -30,9 +30,8 @@ from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect +from pod.meeting.models import Livestream from rest_framework import status - -from pod.bbb.models import Livestream from .forms import EventPasswordForm, EventForm, EventDeleteForm, EventImmediateForm from .models import ( Building, @@ -55,8 +54,8 @@ HEARTBEAT_DELAY = getattr(settings, "HEARTBEAT_DELAY", 45) -USE_BBB = getattr(settings, "USE_BBB", False) -USE_BBB_LIVE = getattr(settings, "USE_BBB_LIVE", False) +USE_MEETING = getattr(settings, "USE_MEETING", False) +USE_MEETING_WEBINAR = getattr(settings, "USE_MEETING_WEBINAR", False) DEFAULT_EVENT_PATH = getattr(settings, "DEFAULT_EVENT_PATH", "") DEFAULT_EVENT_THUMBNAIL = getattr( @@ -124,18 +123,11 @@ def direct(request, slug): "%s?%sreferrer=%s" % (settings.LOGIN_URL, iframe_param, request.get_full_path()) ) - # Search if broadcaster is used to display a BBB streaming live - # for which students can send message from this live page - display_chat = False - if USE_BBB and USE_BBB_LIVE: - livestreams_list = Livestream.objects.filter(broadcaster_id=broadcaster.id) - for livestream in livestreams_list: - display_chat = livestream.enable_chat + return render( request, "live/direct.html", { - "display_chat": display_chat, "display_event_btn": can_manage_event(request.user), "broadcaster": broadcaster, "heartbeat_delay": HEARTBEAT_DELAY, @@ -340,20 +332,20 @@ def render_event_template(request, evemnt, user_owns_event): }, ) - # Search if broadcaster is used to display a BBB streaming live + # Search if livestream is used to display a webinar streaming live # for which students can send message from this live page - display_chat = False - if USE_BBB and USE_BBB_LIVE: - livestreams_list = Livestream.objects.filter(broadcaster_id=evemnt.broadcaster_id) - for livestream in livestreams_list: - display_chat = livestream.enable_chat + enable_chat = False + if USE_MEETING and USE_MEETING_WEBINAR: + livestream = Livestream.objects.filter(event=evemnt).first() + if livestream: + enable_chat = livestream.meeting.enable_chat return render( request, template_event, { "event": evemnt, - "display_chat": display_chat, + "enable_chat": enable_chat, "can_record": ( user_owns_event and evemnt.broadcaster.piloting_implementation diff --git a/pod/locale/fr/LC_MESSAGES/django.mo b/pod/locale/fr/LC_MESSAGES/django.mo index 4635b01fb065d43474dc447715b45310daf86175..dcc0378f4a10c6415b0995463850048fce7bf26e 100644 GIT binary patch delta 52534 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 diff --git a/pod/locale/fr/LC_MESSAGES/django.po b/pod/locale/fr/LC_MESSAGES/django.po index 98de6ba299..690bdd3c39 100644 --- a/pod/locale/fr/LC_MESSAGES/django.po +++ b/pod/locale/fr/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-11 20:03+0000\n" +"POT-Creation-Date: 2024-04-12 12:54+0200\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -461,19 +461,19 @@ msgstr "Date de fin" msgid "End date of the live." msgstr "Date de fin du direct." -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Live not started" msgstr "Le direct n’a pas encore démarré" -#: pod/bbb/models.py pod/bbb/templates/bbb/live_card.html +#: pod/bbb/models.py pod/bbb/templates/bbb/live_card.html pod/meeting/models.py msgid "Live in progress" msgstr "Le direct est en cours" -#: pod/bbb/models.py pod/bbb/templates/bbb/live_card.html +#: pod/bbb/models.py pod/bbb/templates/bbb/live_card.html pod/meeting/models.py msgid "Live stopped" msgstr "Le direct a été arrêté" -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Live status" msgstr "Statut du direct" @@ -500,7 +500,7 @@ msgstr "Accès restreint" msgid "Is live only accessible to authenticated users?" msgstr "Le direct est-il uniquement accessible aux utilisateurs authentifiés ?" -#: pod/bbb/models.py pod/live/admin.py pod/live/models.py +#: pod/bbb/models.py pod/live/admin.py pod/live/models.py pod/meeting/models.py msgid "Broadcaster" msgstr "Diffuseur" @@ -510,11 +510,11 @@ msgstr "Diffuseur en charge de réaliser le direct." #: pod/bbb/models.py msgid "Show public chat" -msgstr "Affichage du tchat public" +msgstr "Affichage du chat public" #: pod/bbb/models.py msgid "Do you want to show the public chat in the live?" -msgstr "Souhaitez-vous montrer le tchat public en direct ?" +msgstr "Souhaitez-vous montrer le chat public en direct ?" #: pod/bbb/models.py msgid "Save meeting in dashboard" @@ -528,17 +528,17 @@ msgstr "" "Souhaitez-vous enregistrer la vidéo de cette session, à la fin du direct, " "directement dans le « Tableau de bord » ?" -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Enable chat" -msgstr "Activer le tchat" +msgstr "Activer le chat" #: pod/bbb/models.py msgid "" "Do you want a chat on the live page for students? Messages sent in this live " "page’s chat will end up in BigBlueButton’s public chat." msgstr "" -"Voulez-vous un tchat sur la page de direct pour les étudiants ? Les messages " -"envoyés dans le tchat de cette page de direct se retrouveront dans le tchat " +"Voulez-vous un chat sur la page de direct pour les étudiants ? Les messages " +"envoyés dans le chat de cette page de direct se retrouveront dans le chat " "public de BigBlueButton." #: pod/bbb/models.py @@ -547,7 +547,7 @@ msgstr "Nom d’hôte REDIS" #: pod/bbb/models.py msgid "Redis hostname, useful for chat" -msgstr "Nom d’hôte REDIS, utile pour le tchat" +msgstr "Nom d’hôte REDIS, utile pour le chat" #: pod/bbb/models.py msgid "Redis port" @@ -555,7 +555,7 @@ msgstr "Port REDIS" #: pod/bbb/models.py msgid "Redis port, useful for chat" -msgstr "Port REDIS, utile pour le tchat" +msgstr "Port REDIS, utile pour le chat" #: pod/bbb/models.py msgid "Redis channel" @@ -563,13 +563,13 @@ msgstr "Channel REDIS" #: pod/bbb/models.py msgid "Redis channel, useful for chat" -msgstr "Channel REDIS, utile pour le tchat" +msgstr "Channel REDIS, utile pour le chat" -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Livestream" msgstr "Direct" -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Livestreams" msgstr "Directs" @@ -2600,8 +2600,8 @@ msgid "" "a>." msgstr "" "L’accès à l’ajout d’enregistrements externes a été limité. Si vous souhaitez " -"ajouter des enregistrements externes sur la plateforme, veuillez nous contacter." +"ajouter des enregistrements externes sur la plateforme, veuillez nous contacter." #: pod/import_video/templates/import_video/add_or_edit.html msgid "" @@ -2672,6 +2672,7 @@ msgstr "" "complétées." #: pod/import_video/templates/import_video/add_or_edit.html +#: pod/meeting/templates/meeting/filter_aside_meeting.html msgid "Useful tips" msgstr "Conseils pratiques" @@ -2976,8 +2977,8 @@ msgid "" "This video was uploaded to Pod; its origin is %(type)s: %(url)s" msgstr "" -"Cette vidéo a été téléversée sur Pod ; son origine est %(type)s : %(url)s" +"Cette vidéo a été téléversée sur Pod ; son origine est %(type)s : %(url)s" #: pod/import_video/views.py msgid "" @@ -2991,8 +2992,8 @@ msgstr "" #: pod/import_video/views.py #, python-format msgid "" -"This video “%(name)s” was uploaded to Pod; its origin is %(type)s: %(url)s

    %(desc)s" +"This video “%(name)s” was uploaded to Pod; its origin is %(type)s: %(url)s

    %(desc)s" msgstr "" "Cette vidéo « %(name)s » a été téléversée sur Pod ; son origine est " "%(type)s : %(url)s

    %(desc)s" @@ -3000,8 +3001,8 @@ msgstr "" #: pod/import_video/views.py #, python-format msgid "" -"This video “%(name)s” was uploaded to Pod; its origin is Youtube: %(url)s" +"This video “%(name)s” was uploaded to Pod; its origin is Youtube: %(url)s" msgstr "" "Cette vidéo « %(name)s » a été téléversée sur Pod ; son origine est " "Youtube : %(url)s" @@ -3469,31 +3470,6 @@ msgstr "Signal" msgid "Heartbeats" msgstr "Signaux" -#: pod/live/templates/bbb/bbb_form.html -msgid "Send message" -msgstr "Envoyer un message" - -#: pod/live/templates/bbb/bbb_form.html -msgid "" -"You can send a message (100 characters maximum) to the BigBlueButton " -"session. It will be displayed within 15 to 30 seconds on the live video." -msgstr "" -"Vous pouvez envoyer un message (100 caractères maximum) à la session " -"BigBlueButton. Il sera affiché dans les 15 à 30 secondes sur la vidéo en " -"direct." - -#: pod/live/templates/bbb/bbb_form.html -msgid "Message" -msgstr "Message" - -#: pod/live/templates/bbb/bbb_form.html -msgid "You must be authenticated to send a message." -msgstr "Vous devez être authentifié pour envoyer un message." - -#: pod/live/templates/bbb/bbb_form.html pod/main/templates/aside.html -msgid "Submit" -msgstr "Envoyer" - #: pod/live/templates/live/direct.html #: pod/live/templates/live/event-script.html msgid "Recording in progress" @@ -3574,21 +3550,6 @@ msgstr "" "Le direct n’a pas encore commencé, nouvelle tentative de lecture dans 10 " "secondes" -#: pod/live/templates/live/direct.html -#: pod/live/templates/live/event-script.html -msgid "Message sent" -msgstr "Message envoyé" - -#: pod/live/templates/live/direct.html -#: pod/live/templates/live/event-script.html -msgid "Message not sent: no broadcaster found" -msgstr "Message non envoyé: aucun diffuseur trouvé" - -#: pod/live/templates/live/direct.html -#: pod/live/templates/live/event-script.html -msgid "Message not sent: connection problem (REDIS)" -msgstr "Message non envoyé: problème de connexion (REDIS)" - #: pod/live/templates/live/directs_all.html msgid "Display all broadcasters of this building" msgstr "Afficher tous les diffuseurs de ce bâtiment" @@ -3794,6 +3755,14 @@ msgstr "Impossible de fermer le flux vidéo" msgid "Recording duration" msgstr "Temps d’enregistrement" +#: pod/live/templates/live/event-script.html +msgid "Message sent" +msgstr "Message envoyé" + +#: pod/live/templates/live/event-script.html +msgid "Message not sent" +msgstr "Le message n'a pas été envoyé" + #: pod/live/templates/live/event.html pod/live/templates/live/event_delete.html #: pod/live/templates/live/event_edit.html #: pod/live/templates/live/event_immediate_edit.html pod/live/views.py @@ -4023,6 +3992,34 @@ msgstr "Évènements à venir" msgid "Past events" msgstr "Évènements passés" +#: pod/live/templates/meeting/meeting_live_form.html +msgid "Send message" +msgstr "Envoyer un message" + +#: pod/live/templates/meeting/meeting_live_form.html +msgid "" +"You can send a message to the webinar presenters (100 characters maximum)." +msgstr "" +"Vous pouvez envoyer un message aux présentateurs du webinaire (100 " +"caractères maximum)." + +#: pod/live/templates/meeting/meeting_live_form.html +msgid "It will be displayed after 10 to 30 seconds on the live stream." +msgstr "Il sera affiché après 10 à 30 secondes lors de la diffusion en direct." + +#: pod/live/templates/meeting/meeting_live_form.html +msgid "Message" +msgstr "Message" + +#: pod/live/templates/meeting/meeting_live_form.html +#: pod/main/templates/aside.html +msgid "Submit" +msgstr "Envoyer" + +#: pod/live/templates/meeting/meeting_live_form.html +msgid "You must be authenticated to send a message." +msgstr "Vous devez être authentifié pour envoyer un message." + #: pod/live/templatetags/event_tags.py msgid "QR code event’s link" msgstr "QR code pour le lien de l’évènement" @@ -5403,6 +5400,10 @@ msgstr "rejoindre" msgid "Recurring" msgstr "Récurrence" +#: pod/meeting/admin.py pod/meeting/forms.py +msgid "Webinar options" +msgstr "Options du webinaire" + #: pod/meeting/forms.py pod/video/feeds.py pod/video/models.py #: pod/video/templates/videos/video_row_select.html #: pod/video/templates/videos/video_sort_select.html @@ -5707,6 +5708,26 @@ msgstr "" "Autoriser l’utilisateur à arrêter/démarrer l’enregistrement. (vrai par " "défaut)" +#: pod/meeting/models.py +msgid "Always accept" +msgstr "Toujours accepter" + +#: pod/meeting/models.py +msgid "Always deny" +msgstr "Toujours refuser" + +#: pod/meeting/models.py +msgid "Ask moderator" +msgstr "Demander au modérateur" + +#: pod/meeting/models.py +msgid "Guest policy" +msgstr "Politique à l’égard des invités" + +#: pod/meeting/models.py +msgid "Will set the guest policy for the meeting." +msgstr "Fixera la politique des invités pour la réunion." + #: pod/meeting/models.py msgid "Disable Camera" msgstr "Désactiver les caméras" @@ -5783,6 +5804,32 @@ msgstr "" "Si cette case est cochée, cette réunion correspond à la salle de réunion " "personnelle de l’utilisateur." +#: pod/meeting/models.py +msgid "Webinar mode" +msgstr "Mode webinaire" + +#: pod/meeting/models.py +msgid "" +"Do you want to start this meeting as a webinar? In such a case, you can " +"invite presenters to join you in BigBlueButton, and listeners will have " +"direct access to a livestream in the livestreams page. " +msgstr "" +"Souhaitez-vous commencer cette réunion sous la forme d'un webinaire ? Dans " +"ce cas, vous pouvez inviter les présentateurs à se joindre à vous dans " +"BigBlueButton, et les auditeurs auront un accès via un direct dans la page " +"des directs." + +#: pod/meeting/models.py +msgid "" +"Do you want a chat on the live page for listeners? Messages sent in this " +"live page's chat will end up in BigBlueButton's public chat. This public " +"chat will be also displayed in the live." +msgstr "" +"Voulez-vous un chat sur la page en direct pour les auditeurs ? Les messages " +"envoyés dans le chat de cette page en direct se retrouveront dans le chat " +"public de BigBlueButton. Cette discussion publique sera également affichée " +"en direct." + #: pod/meeting/models.py msgid "" "The day of the start date of the meeting must be included in the recurrence " @@ -5811,6 +5858,42 @@ msgstr "Identifiant de l’enregistrement" msgid "Recordings" msgstr "Enregistrements" +#: pod/meeting/models.py +msgid "URL of the RTMP stream" +msgstr "Adresse URL du flux RTMP" + +#: pod/meeting/models.py +msgid "Example format: rtmp://live.univ.fr/live/name" +msgstr "Exemple de format : rtmp://live.univ.fr/live/name" + +#: pod/meeting/models.py +msgid "Broadcaster in charge to perform lives." +msgstr "Diffuseur chargé de réaliser des directs." + +#: pod/meeting/models.py +msgid "Live gateway" +msgstr "Passerelle de live" + +#: pod/meeting/models.py +msgid "Live gateways" +msgstr "Passerelles de live" + +#: pod/meeting/models.py +msgid "Event managed for this live" +msgstr "Gestion de l'événement pour ce direct" + +#: pod/meeting/models.py +msgid "Live event for this livestream" +msgstr "Événement en direct pour cette diffusion" + +#: pod/meeting/models.py +msgid "Live gateway used for this live" +msgstr "Passerelle de live utilisé pour ce direct" + +#: pod/meeting/models.py +msgid "Live gateway (encoder and broadcaster) that perform the livestream" +msgstr "Passerelle de live (encodeur et diffuseur) qui réalise la diffusion" + #: pod/meeting/templates/meeting/add_or_edit.html #: pod/meeting/templates/meeting/link_meeting.html pod/meeting/views.py msgid "Edit the meeting" @@ -5880,6 +5963,140 @@ msgstr "Voir mes réunions actives" msgid "See all my meetings" msgstr "Voir toutes mes réunions" +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "Informations about meetings" +msgstr "Informations sur les réunions" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"This meeting module is based on the OpenSource BigBlueButton solution." +msgstr "" +"Ce module de réunion est basé sur la solution OpenSource " +"BigBlueButton." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"This solution enables voice and video image sharing, presentations with or " +"without a whiteboard, public and private chat tools, screen sharing, voice " +"over IP, online polling and the use of office documents." +msgstr "" +"Cette solution permet le partage d’images vocales et vidéo, des " +"présentations avec ou sans tableau blanc, des outils de chat public et " +"privé, le partage d’écran, la voix sur IP, les sondages en ligne et " +"l’utilisation de documents bureautiques." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "Informations about webinars" +msgstr "Informations sur les webinaires" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"If you want to hold an online conference for a large audience (over 200 " +"users), you can use the Webinar mode, accessible from this meetings module." +msgstr "" +"Si vous souhaitez organiser une conférence en ligne pour un large public " +"(plus de 200 utilisateurs), vous pouvez utiliser le mode Webinaire, " +"accessible à partir de ce module de réunions." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"This Webinar mode enables you to transmit information to a large audience " +"via a live broadcast (accessible from the platform's live page) and " +"interaction - if you wish - via an integrated chat." +msgstr "" +"Ce mode Webinaire vous permet de transmettre des informations à un large " +"public via une diffusion en direct (accessible depuis la page des directs de " +"la plateforme) et une interaction - si vous le souhaitez - via un chat " +"intégré." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Once you've saved the form, you can start the webinar by clicking on the " +"'Start the webinar' button." +msgstr "" +"Une fois le formulaire enregistré, vous pouvez démarrer le webinaire en " +"cliquant sur le bouton « Démarrer le webinaire »." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Shortly after clicking the 'Start the webinar' button, the live stream will " +"be available to users on the Lives page." +msgstr "" +"Peu de temps après avoir cliqué sur le bouton « Démarrer le webinaire », le " +"direct sera disponible pour les utilisateurs sur la page Directs." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"You can invite other speakers/trainers to join you in BigBlueButton. Live " +"should only be used by listeners." +msgstr "" +"Vous pouvez inviter d'autres orateurs/formateurs à vous rejoindre dans " +"BigBlueButton. Le direct ne doit être utilisé que par les auditeurs." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Once the webinar has been created, you can modify the date and time " +"information as you wish, and the live webinar will be updated accordingly." +msgstr "" +"Une fois le webinaire créé, vous pouvez modifier la date et l’heure à votre " +"guise, et le webinaire en direct sera mis à jour en conséquence." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "This can be very useful for pre-event testing." +msgstr "" +"Cela peut s’avérer très utile pour les tests préalables aux événements." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Once started, you can access the webinar information and additional actions " +"via " +"Show webinar informations in the meetings list." +msgstr "" +"Une fois démarré, vous pouvez accéder aux informations sur le webinaire et à " +"des actions supplémentaires via Afficher les informations sur le webinaire dans la liste des réunions." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"As you have the appropriate rights, once the webinar has been created, you " +"can access additional settings for the created event via My Events in the " +"main menu." +msgstr "" +"Comme vous disposez des droits appropriés, une fois le webinaire créé, vous " +"pouvez accéder à des paramètres supplémentaires pour l'événement créé via Mes événements dans le menu principal." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "Recommendations" +msgstr "Recommandations" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "There are just a few recommendations to follow: " +msgstr "Il y a juste quelques recommandations à suivre : " + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Please do not end the meeting before it is actually over." +msgstr "" +"Veuillez ne pas terminer la réunion avant qu’elle ne soit " +"réellement terminée." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "No recurrence available for webinars." +msgstr "Aucune récurrence disponible pour les webinaires." + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "Remember to not use breakout rooms in this case." +msgstr "" +"Rappelez-vous de ne pas utiliser les salles privées dans ce " +"cas." + #: pod/meeting/templates/meeting/internal_recordings.html msgid "" "After recording a Big Blue Button meeting, recordings of that meeting will " @@ -5999,6 +6216,10 @@ msgstr "" "Il s’agit de votre salle de réunion personnelle, une salle spécifique à " "votre profil, qui est toujours disponible." +#: pod/meeting/templates/meeting/meeting_card.html +msgid "This meeting is a webinar" +msgstr "Cette réunion est un webinaire" + #: pod/meeting/templates/meeting/meeting_card.html msgid "Access to this meeting is restricted" msgstr "L’accès à cette réunion est restreint" @@ -6007,10 +6228,22 @@ msgstr "L’accès à cette réunion est restreint" msgid "This meeting is inactive" msgstr "Cette réunion est inactive" +#: pod/meeting/templates/meeting/meeting_card.html +msgid "Show webinar informations" +msgstr "Voir les informations sur le webinaire" + +#: pod/meeting/templates/meeting/meeting_card.html +msgid "Join the webinar" +msgstr "Rejoindre le webinaire" + #: pod/meeting/templates/meeting/meeting_card.html msgid "Show meeting informations" msgstr "Voir les informations sur la réunion" +#: pod/meeting/templates/meeting/meeting_card.html +msgid "Start the webinar" +msgstr "Démarrer le webinaire" + #: pod/meeting/templates/meeting/meeting_card.html msgid "Start the meeting" msgstr "Démarrer la réunion" @@ -6117,6 +6350,16 @@ msgstr "Nombre de modérateurs" msgid "You cannot edit this meeting." msgstr "Vous ne pouvez pas éditer cette réunion." +#: pod/meeting/views.py +msgid "" +"It is not possible to hold a webinar during this period. Webinar mode has " +"been disabled for this meeting. Please try to change the period or contact " +"the administrator." +msgstr "" +"Il n’est pas possible d’organiser un webinaire pendant cette période. Le " +"mode webinaire a été désactivé pour cette réunion. Veuillez essayer de " +"modifier la période ou contacter l’administrateur." + #: pod/meeting/views.py msgid "You cannot delete this meeting." msgstr "Vous ne pouvez pas supprimer cette réunion." @@ -6137,6 +6380,14 @@ msgstr "Vous ne pouvez pas accéder à cette réunion." msgid "Password given is not correct." msgstr "Le mot de passe est incorrect." +#: pod/meeting/views.py +msgid "You cannot end this meeting." +msgstr "Vous ne pouvez pas arrêter cette réunion." + +#: pod/meeting/views.py +msgid "The meeting was successfully stopped." +msgstr "La réunion a été arrêtée avec succès." + #: pod/meeting/views.py msgid "Meeting recordings" msgstr "Enregistrements de réunion" @@ -6180,16 +6431,16 @@ msgid "" msgstr "" "\n" "

    Bonjour,\n" -"

    %(owner)s vous invite à une réunion récurrente " -"%(meeting_title)s.

    \n" +"

    %(owner)s vous invite à une réunion récurrente " +"%(meeting_title)s.

    \n" "

    Date de début : %(start_date_time)s

    \n" "

    Récurrent jusqu’à la date : %(end_date)s

    \n" "

    La réunion se tiendra tou(te)s les %(frequency)s %(recurrence)s \n" "

    Voici le lien pour rejoindre la réunion :\n" " %(join_link)s

    \n" -"

    Vous avez besoin de ce mot de passe pour entrer : " -"%(password)s

    \n" +"

    Vous avez besoin de ce mot de passe pour entrer : " +"%(password)s

    \n" "

    Cordialement

    \n" " " @@ -6198,8 +6449,8 @@ msgstr "" msgid "" "\n" "

    Hello,

    \n" -"

    %(owner)s invites you to the meeting " -"%(meeting_title)s.

    \n" +"

    %(owner)s invites you to the meeting " +"%(meeting_title)s.

    \n" "

    here the link to join the meeting:\n" " %(join_link)s

    \n" "

    You need this password to enter: %(password)s.

    \n" "

    Voici le lien pour rejoindre la réunion :\n" " %(join_link)s

    \n" -"

    Vous avez besoin de ce mot de passe pour entrer : " -"%(password)s

    \n" +"

    Vous avez besoin de ce mot de passe pour entrer : " +"%(password)s

    \n" "

    Cordialement

    \n" " " @@ -6223,8 +6474,8 @@ msgstr "" msgid "" "\n" "

    Hello,

    \n" -"

    %(owner)s invites you to the meeting " -"%(meeting_title)s.

    \n" +"

    %(owner)s invites you to the meeting " +"%(meeting_title)s.

    \n" "

    Start date: %(start_date_time)s

    \n" "

    End date: %(end_date)s

    \n" "

    here the link to join the meeting:\n" @@ -6242,8 +6493,8 @@ msgstr "" "

    Date de fin : %(end_date)s

    \n" "

    Voici le lien pour rejoindre la réunion :\n" " %(join_link)s

    \n" -"

    Vous avez besoin de ce mot de passe pour entrer : " -"%(password)s

    \n" +"

    Vous avez besoin de ce mot de passe pour entrer : " +"%(password)s

    \n" "

    Cordialement

    \n" " " @@ -6264,6 +6515,82 @@ msgstr "" msgid "Impossible to create the internal recording" msgstr "Impossible de créer l’enregistrement" +#: pod/meeting/views.py +msgid "You can't end this webinar live." +msgstr "Vous ne pouvez pas terminer ce webinaire en direct." + +#: pod/meeting/views.py +msgid "You can't restart this webinar live." +msgstr "Vous ne pouvez pas redémarrer ce webinaire en direct." + +#: pod/meeting/webinar.py +#, python-format +msgid "Webinar mode has been successfully started for “%s” meeting." +msgstr "Le mode webinaire a bien été démarré pour la réunion “%s”." + +#: pod/meeting/webinar.py +#, python-format +msgid "Error to start webinar mode for “%s” meeting: %s" +msgstr "Erreur de démarrage du mode webinaire pour la réunion “%s” : %s" + +#: pod/meeting/webinar.py +#, python-format +msgid "Webinar mode has been successfully stopped for “%s” meeting." +msgstr "Le mode webinaire a bien été arrêté pour la réunion “%s”." + +#: pod/meeting/webinar.py +#, python-format +msgid "Error to stop webinar mode for “%s” meeting: %s" +msgstr "Erreur dans l’arrêt du mode webinaire pour la réunion “%s” : %s" + +#: pod/meeting/webinar.py +msgid "" +"it is not possible to use a development server (localhost) for this " +"functionality." +msgstr "" +"il n’est pas possible d’utiliser un serveur de développement (localhost) " +"pour cette fonctionnalité." + +#: pod/meeting/webinar_utils.py +msgid "Too many webinars" +msgstr "Trop de webinaires" + +#: pod/meeting/webinar_utils.py +#, python-format +msgid "" +"There are too many webinars (%s) for the number of live gateways allocated " +"(%s). The next meeting has been created but not like a webinar:%s %s [%s-" +"%s].\n" +"Please fix the problem either by increasing the number of live gateways or " +"by modifying/deleting one of the affected webinars (with the users' " +"agreement).\n" +"Other webinars: %s" +msgstr "" +"Il y a trop de webinaires (%s) pour le nombre de passerelles de live " +"allouées (%s). La prochaine réunion a été créée mais pas comme un webinaire :" +"%s %s [%s-%s].\n" +"Veuillez résoudre le problème en augmentant le nombre de passerelles de live " +"ou en modifiant/supprimant l’un des webinaires concernés (avec l’accord des " +"utilisateurs).\n" +"Autres webinaires : %s" + +#: pod/meeting/webinar_utils.py +#, python-format +msgid "" +"

    There are too many webinars (%s) for the number of live gateways " +"allocated (%s). The next webinar has been created but not like a " +"webinar:

    • %s %s [%s-%s].

    Please fix the problem " +"either by increasing the number of live gateways or by modifying/deleting " +"one of the affected webinars (with the users' agreement).
    Other webinars: " +"%s" +msgstr "" +"

    Il y a trop de webinaires (%s) pour le nombre de passerelles live " +"allouées (%s). La prochaine réunion a été créée mais pas comme un " +"webinaire :

    • %s %s [%s-%s].

    Veuillez résoudre " +"le problème en augmentant le nombre de passerelles live ou en modifiant/" +"supprimant l’un des webinaires concernés (avec l'accord des utilisateurs)." +"
    Autres webinaires : %s" + #: pod/playlist/apps.py pod/playlist/models.py #: pod/playlist/templates/playlist/add_or_edit.html #: pod/playlist/templates/playlist/delete.html @@ -7289,8 +7616,8 @@ msgstr "Prévisualisation d’enregistrement" #: pod/video/templates/videos/video-element.html msgid "" "To view this video please enable JavaScript, and consider upgrading to a web " -"browser that supports HTML5 video" +"browser that supports HTML5 video" msgstr "" "Pour visionner cette vidéo, veuillez activer JavaScript et envisager de " "passer à un navigateur Web qui Bonjour,
    un nouvel enregistrement a été ajouté sur la plateforme " "%(title_site)s à partir de l’enregistreur « %(recorder)s ».
    Pour " -"l’ajouter, cliquez sur le lien ci-dessous.

    %(link_url)s
    Si le lien n’est pas actif, il " -"faut le copier-coller dans la barre d’adresse de votre navigateur.

    Cordialement.

    " +"l’ajouter, cliquez sur le lien ci-dessous.

    " +"%(link_url)s
    Si le lien n’est pas actif, il faut le copier-coller " +"dans la barre d’adresse de votre navigateur.

    Cordialement.

    " #: pod/recorder/views.py msgid "New recording added." @@ -7719,8 +8045,8 @@ msgid "" "%(url)s

    \n" msgstr "" "vous pouvez changer la date de suppression en éditant votre vidéo :

    \n" -"

    %(scheme)s:%(url)s

    \n" +"

    " +"%(scheme)s:%(url)s

    \n" "\n" #: pod/video/management/commands/check_obsolete_videos.py @@ -8585,8 +8911,8 @@ msgid "" "This video is chaptered. Click the chapter button on the video player to view them." msgstr "" -"Cette vidéo est chapitrée. Cliquez sur le bouton de chapitre sur le lecteur vidéo pour les voir." +"Cette vidéo est chapitrée. Cliquez sur le bouton de chapitre sur le lecteur vidéo pour les voir." #: pod/video/templates/videos/video-all-info.html msgid "Other versions" diff --git a/pod/locale/fr/LC_MESSAGES/djangojs.mo b/pod/locale/fr/LC_MESSAGES/djangojs.mo index 4036a1ad3eb9460cd8488edf62d3fd87e069a1df..0d24fd08eff7ef72a1def68bbb8a189074cdec35 100644 GIT binary patch delta 5340 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 delta 4492 zcmYk0 zW=&!Z#v@8a6LPF#R5Ui$M65Gvjh#-Dv8MkxHkq*wX;WjYeSh|OoXInM_Vs(7-Tghk z^I1K%+p~MS$A2|3;&tOVN7Bg3NV7PPS!=AWn*FP@Sp?p~Z2S*;F|~`?033~ju>u!j z19ro^Sb@ojX2Wo;>tP(k`L{UA%x~#QX6amb48!pmt_SErxC#r|d$SB!Ys42OH z8i{|SZs_e{)(HnG@z)TIWP&vG<*16b zU@7iLwe$znBJ7>&+_(>_ArGNCFdk#D42R=FoPpa=4fqDNc%wPd6y%}4SCYo~>xSiA z(9qQ(f3}gA2XHUyi{~&OZz6w|K`l(9jYdtyOjHM!p?bIt)x%?`4qipo<6)&Q!yM$2 z)%z*vMhD#smrysjiC&D&a8`95Y9tmQf3|@aO;sams=h!C@eS0CQ@l>cMkAwbWv@idI{-(->y?o%eyj8{c!?nE}uZH_SLBO*P#~O2dEpgy60`E#d!tQbG@ho*{HQp z=APHPobfITsX`*&`n0&5|C-MA*dG4!LeA63-JQ7 zF>D|!XERPmy?+%8(aS;^j|*@f?m_kVM^ppT*u&bk*{H=nDxdLJh10p9Ij+J9cmSv2 zP2}lmg{-I|EWM^0jO*R=^Eiz2D281fd|2bJ!YWjS z2QUtAU_5@0dQe0@_o$wy4sm*%g^a!pchCKc zDX7JD7>UngGH%2!*noP!8I$m1RKw1r8ulfs=eKbYhS3|{U@5AhTTu1vLrv8Y)b6;A z;oAR6j7I?%x??gvim~WJt=<|`j}D_3|BB3t-9|0iTsFBrw*d( zq~cUujLUGS_J11%Ey8=K)fz#qiP#xcQ5JT?LY#}UP|u0?Pz}jtqSe58=*2at&+kC( zjzicTzeF{t9hp^2V!=Jg{jHcn5!PZZwxG7(HB<%Bqn$;Uk4&dkqi*~Ts-nxt2CzuB zKqU@A)w2hw!p@`CLNp6369=H`e+2!!XLS_#vqQXS8@8h=P8s8jKnd!7AEw}5RF6+! zFT92s_&;QaT6&SQCQ49?Zx-r%^N~TeTGU847cu@Cs$*PG%Pyf7TRZBJdKcB8tg+6b znv7aZPociI9#v5TX5xPI;yI)(_6_o9{l_^YQHk1JO{m3wWSrl5hM(ku7TtCC1K*)4 z4&!OUv%}(1tGW=2aRoB{_CC(U8>p!%&Z{zED36J7(_G$)ZQi*sX} zg_m$P{u$?B?j+||Y#p+*>?7=f*D(z}ENL|;9g}bhs=?)`AD0T$_o}f!?!{ERi2B^O zI1K&&rJxq)(~2CNk6NuSVgkN}iFgq8#gnKhIEO#OuTVYDo$4&Ya%4T)%UFbGk-@Q! zC2V1whPq!kU-oMM52Td7OaD^BI}DK8_(iRN98UL9)ST(V6k z9Gc^pz=v^3es$?(qW$x8l1|o<&q;rBh-hV~EBB8NDO9*81F()T@3z&wCZ>>`?)lr; zKx)V@h=xbUYZ~8YC>$W#FFK}^exVZoMRC2)n-sKqW|4Q@i<-;`GLI}G8;Bknzb9Gb z9kQ3C5gmOUY*k3VbEXMi>b}8Gru~-eCnE@3BeWNu#wSTBVNPuU(aO^PSxj^^ljTI! zZXu`1b7TiuNFs@jlMd$72er=inA%NRNeX#|=om$wCtC79JsLS#Ps-ejD}VYXo=+J5+N1<++5i{}?aj~DWYQ!d7lg=L&&pa1^I{^AxT8XWO9yFlE+AA z5=Qcfjz2osqqvDY;GRE@wZuza(u!Mwz+VZk4huZ9QOQ`U5-t61u* z4({tQ!P9Bg3ZJ*q=UY`#wLFlUdL~eqmLBYyb~G|@r}yQ+<*a`OKg({64Ym%t8xgoV zY\n" "Language-Team: \n" @@ -179,11 +179,19 @@ msgstr "Mettez en pause pour entrer le texte du segment entre %s et %s." msgid "A caption cannot contain more than 80 characters." msgstr "Une légende / sous-titre ne peut comporter plus de 80 caractères." +#: pod/completion/static/js/caption_maker.js +msgid "Add a caption/subtitle after this one" +msgstr "Ajouter un(e) légende/sous-titre après celui-ci" + #: pod/completion/static/js/caption_maker.js #: pod/podfile/static/podfile/js/filewidget.js msgid "Add" msgstr "Ajouter" +#: pod/completion/static/js/caption_maker.js +msgid "Delete this caption/subtitle" +msgstr "Supprimer ce(tte) légende/sous-titre" + #: pod/completion/static/js/caption_maker.js #: pod/video/static/js/comment-script.js msgid "Delete" @@ -493,6 +501,18 @@ msgstr "Information" msgid "Unable to find information about the meeting" msgstr "Impossible de trouver des informations sur la réunion" +#: pod/meeting/static/js/my_meetings.js +msgid "Restart only the live" +msgstr "Redémarrer seulement le direct" + +#: pod/meeting/static/js/my_meetings.js +msgid "End only the live" +msgstr "Arrêter seulement le direct" + +#: pod/meeting/static/js/my_meetings.js +msgid "End the webinar (meeting and live)" +msgstr "Terminer le webinaire (réunion et direct)" + #: pod/meeting/static/js/my_meetings.js msgid "End the meeting" msgstr "Terminer la réunion" @@ -538,6 +558,14 @@ msgstr "La réponse du réseau n’était pas correcte." msgid "Loading…" msgstr "Chargement en cours…" +#: pod/podfile/static/podfile/js/filewidget.js +msgid "Change image" +msgstr "Changer d’image" + +#: pod/podfile/static/podfile/js/filewidget.js +msgid "Change file" +msgstr "Changer de fichier" + #: pod/podfile/static/podfile/js/filewidget.js msgid "Open file in a new tab" msgstr "Ouvrir le fichier dans un nouvel onglet" @@ -660,14 +688,37 @@ msgstr "Réponses" msgid "Cancel" msgstr "Annuler" +#: pod/video/static/js/comment-script.js +#, javascript-format +msgid "%s vote" +msgid_plural "%s votes" +msgstr[0] "%s vote" +msgstr[1] "%s votes" + #: pod/video/static/js/comment-script.js msgid "Agree with the comment" msgstr "D’accord avec ce commentaire" +#: pod/video/static/js/comment-script.js +msgid "Reply to comment" +msgstr "Répondre au commentaire" + +#: pod/video/static/js/comment-script.js +msgid "Reply" +msgstr "Répondre" + #: pod/video/static/js/comment-script.js msgid "Remove this comment" msgstr "Supprimer ce commentaire" +#: pod/video/static/js/comment-script.js +msgid "Add a public comment" +msgstr "Ajouter un commentaire public" + +#: pod/video/static/js/comment-script.js +msgid "Send" +msgstr "Envoyer" + #: pod/video/static/js/comment-script.js msgid "Show answers" msgstr "Afficher les réponses" @@ -680,13 +731,6 @@ msgstr "Mauvaise réponse du serveur." msgid "Sorry, you’re not allowed to vote by now." msgstr "Désolé, vous n’êtes pas autorisé à voter maintenant." -#: pod/video/static/js/comment-script.js -#, javascript-format -msgid "%s vote" -msgid_plural "%s votes" -msgstr[0] "%s vote" -msgstr[1] "%s votes" - #: pod/video/static/js/comment-script.js msgid "Sorry, you can’t comment this video by now." msgstr "Désolé, vous ne pouvez pas commenter cette vidéo maintenant." @@ -719,38 +763,47 @@ msgstr[0] "%(count)s vidéo trouvée" msgstr[1] "%(count)s vidéos trouvées" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "This content is password protected." msgstr "Ce contenu est protégé par mot de passe." #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "This content is chaptered." msgstr "Ce contenu est chapitré." #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "This content is in draft." msgstr "Ce contenu est en brouillon." #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Video content." msgstr "Contenu vidéo." #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Audio content." msgstr "Contenu audio." #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Edit the video" msgstr "Éditer la vidéo" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Complete the video" msgstr "Compléter la vidéo" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Chapter the video" msgstr "Chapitrer la vidéo" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Delete the video" msgstr "Supprimer la vidéo" @@ -823,6 +876,18 @@ msgstr "Désolé, aucune vidéo trouvée" msgid "Edit the category" msgstr "Éditer la catégorie" +#: pod/video/static/js/video_category.js +msgid "Delete the category" +msgstr "Supprimer la catégorie" + +#: pod/video/static/js/video_category.js +msgid "Success!" +msgstr "Succès !" + +#: pod/video/static/js/video_category.js +msgid "Error…" +msgstr "Erreur…" + #: pod/video/static/js/video_category.js msgid "Category created successfully" msgstr "Catégorie créée avec succès" @@ -904,36 +969,3 @@ msgstr "Ajouts en favoris total depuis la création" #: pod/video/static/js/video_stats_view.js msgid "Slug" msgstr "Titre court" - -#~ msgid "Add a caption/subtitle after this one" -#~ msgstr "Ajouter un(e) légende/sous-titre après celui-ci" - -#~ msgid "Delete this caption/subtitle" -#~ msgstr "Supprimer ce(tte) légende/sous-titre" - -#~ msgid "Change image" -#~ msgstr "Changer d’image" - -#~ msgid "Change file" -#~ msgstr "Changer de fichier" - -#~ msgid "Reply to comment" -#~ msgstr "Répondre au commentaire" - -#~ msgid "Reply" -#~ msgstr "Répondre" - -#~ msgid "Add a public comment" -#~ msgstr "Ajouter un commentaire public" - -#~ msgid "Send" -#~ msgstr "Envoyer" - -#~ msgid "Delete the category" -#~ msgstr "Supprimer la catégorie" - -#~ msgid "Success!" -#~ msgstr "Succès !" - -#~ msgid "Error…" -#~ msgstr "Erreur…" diff --git a/pod/locale/nl/LC_MESSAGES/django.po b/pod/locale/nl/LC_MESSAGES/django.po index 00fee24050..b014bb895e 100644 --- a/pod/locale/nl/LC_MESSAGES/django.po +++ b/pod/locale/nl/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-11 20:03+0000\n" +"POT-Creation-Date: 2024-04-12 12:54+0200\n" "PO-Revision-Date: 2023-06-08 14:37+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -450,19 +450,19 @@ msgstr "" msgid "End date of the live." msgstr "" -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Live not started" msgstr "" -#: pod/bbb/models.py pod/bbb/templates/bbb/live_card.html +#: pod/bbb/models.py pod/bbb/templates/bbb/live_card.html pod/meeting/models.py msgid "Live in progress" msgstr "" -#: pod/bbb/models.py pod/bbb/templates/bbb/live_card.html +#: pod/bbb/models.py pod/bbb/templates/bbb/live_card.html pod/meeting/models.py msgid "Live stopped" msgstr "" -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Live status" msgstr "" @@ -488,7 +488,7 @@ msgstr "" msgid "Is live only accessible to authenticated users?" msgstr "" -#: pod/bbb/models.py pod/live/admin.py pod/live/models.py +#: pod/bbb/models.py pod/live/admin.py pod/live/models.py pod/meeting/models.py msgid "Broadcaster" msgstr "" @@ -514,7 +514,7 @@ msgid "" "directly in “Dashboard”?" msgstr "" -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Enable chat" msgstr "" @@ -548,11 +548,11 @@ msgstr "" msgid "Redis channel, useful for chat" msgstr "" -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Livestream" msgstr "" -#: pod/bbb/models.py +#: pod/bbb/models.py pod/meeting/models.py msgid "Livestreams" msgstr "" @@ -2526,6 +2526,7 @@ msgid "An email will be sent to you when all encoding tasks are completed." msgstr "" #: pod/import_video/templates/import_video/add_or_edit.html +#: pod/meeting/templates/meeting/filter_aside_meeting.html msgid "Useful tips" msgstr "" @@ -3241,28 +3242,6 @@ msgstr "" msgid "Heartbeats" msgstr "" -#: pod/live/templates/bbb/bbb_form.html -msgid "Send message" -msgstr "" - -#: pod/live/templates/bbb/bbb_form.html -msgid "" -"You can send a message (100 characters maximum) to the BigBlueButton " -"session. It will be displayed within 15 to 30 seconds on the live video." -msgstr "" - -#: pod/live/templates/bbb/bbb_form.html -msgid "Message" -msgstr "" - -#: pod/live/templates/bbb/bbb_form.html -msgid "You must be authenticated to send a message." -msgstr "" - -#: pod/live/templates/bbb/bbb_form.html pod/main/templates/aside.html -msgid "Submit" -msgstr "" - #: pod/live/templates/live/direct.html #: pod/live/templates/live/event-script.html msgid "Recording in progress" @@ -3338,21 +3317,6 @@ msgstr "" msgid "Live not found, retry in 10 seconds" msgstr "" -#: pod/live/templates/live/direct.html -#: pod/live/templates/live/event-script.html -msgid "Message sent" -msgstr "" - -#: pod/live/templates/live/direct.html -#: pod/live/templates/live/event-script.html -msgid "Message not sent: no broadcaster found" -msgstr "" - -#: pod/live/templates/live/direct.html -#: pod/live/templates/live/event-script.html -msgid "Message not sent: connection problem (REDIS)" -msgstr "" - #: pod/live/templates/live/directs_all.html msgid "Display all broadcasters of this building" msgstr "" @@ -3552,6 +3516,14 @@ msgstr "" msgid "Recording duration" msgstr "" +#: pod/live/templates/live/event-script.html +msgid "Message sent" +msgstr "" + +#: pod/live/templates/live/event-script.html +msgid "Message not sent" +msgstr "" + #: pod/live/templates/live/event.html pod/live/templates/live/event_delete.html #: pod/live/templates/live/event_edit.html #: pod/live/templates/live/event_immediate_edit.html pod/live/views.py @@ -3766,6 +3738,32 @@ msgstr "" msgid "Past events" msgstr "" +#: pod/live/templates/meeting/meeting_live_form.html +msgid "Send message" +msgstr "" + +#: pod/live/templates/meeting/meeting_live_form.html +msgid "" +"You can send a message to the webinar presenters (100 characters maximum)." +msgstr "" + +#: pod/live/templates/meeting/meeting_live_form.html +msgid "It will be displayed after 10 to 30 seconds on the live stream." +msgstr "" + +#: pod/live/templates/meeting/meeting_live_form.html +msgid "Message" +msgstr "" + +#: pod/live/templates/meeting/meeting_live_form.html +#: pod/main/templates/aside.html +msgid "Submit" +msgstr "" + +#: pod/live/templates/meeting/meeting_live_form.html +msgid "You must be authenticated to send a message." +msgstr "" + #: pod/live/templatetags/event_tags.py msgid "QR code event’s link" msgstr "" @@ -4649,10 +4647,8 @@ msgid "Write in html inside this field." msgstr "" #: pod/main/models.py -#, fuzzy -#| msgid "Filter by title" msgid "Display title" -msgstr "Filter op titel" +msgstr "" #: pod/main/models.py msgid "No cache" @@ -5132,6 +5128,10 @@ msgstr "" msgid "Recurring" msgstr "" +#: pod/meeting/admin.py pod/meeting/forms.py +msgid "Webinar options" +msgstr "" + #: pod/meeting/forms.py pod/video/feeds.py pod/video/models.py #: pod/video/templates/videos/video_row_select.html #: pod/video/templates/videos/video_sort_select.html @@ -5411,6 +5411,26 @@ msgstr "" msgid "Allow the user to start/stop recording. (default true)" msgstr "" +#: pod/meeting/models.py +msgid "Always accept" +msgstr "" + +#: pod/meeting/models.py +msgid "Always deny" +msgstr "" + +#: pod/meeting/models.py +msgid "Ask moderator" +msgstr "" + +#: pod/meeting/models.py +msgid "Guest policy" +msgstr "" + +#: pod/meeting/models.py +msgid "Will set the guest policy for the meeting." +msgstr "" + #: pod/meeting/models.py msgid "Disable Camera" msgstr "" @@ -5485,6 +5505,24 @@ msgid "" "meeting room." msgstr "" +#: pod/meeting/models.py +msgid "Webinar mode" +msgstr "" + +#: pod/meeting/models.py +msgid "" +"Do you want to start this meeting as a webinar? In such a case, you can " +"invite presenters to join you in BigBlueButton, and listeners will have " +"direct access to a livestream in the livestreams page. " +msgstr "" + +#: pod/meeting/models.py +msgid "" +"Do you want a chat on the live page for listeners? Messages sent in this " +"live page's chat will end up in BigBlueButton's public chat. This public " +"chat will be also displayed in the live." +msgstr "" + #: pod/meeting/models.py msgid "" "The day of the start date of the meeting must be included in the recurrence " @@ -5511,6 +5549,42 @@ msgstr "" msgid "Recordings" msgstr "" +#: pod/meeting/models.py +msgid "URL of the RTMP stream" +msgstr "" + +#: pod/meeting/models.py +msgid "Example format: rtmp://live.univ.fr/live/name" +msgstr "" + +#: pod/meeting/models.py +msgid "Broadcaster in charge to perform lives." +msgstr "" + +#: pod/meeting/models.py +msgid "Live gateway" +msgstr "" + +#: pod/meeting/models.py +msgid "Live gateways" +msgstr "" + +#: pod/meeting/models.py +msgid "Event managed for this live" +msgstr "" + +#: pod/meeting/models.py +msgid "Live event for this livestream" +msgstr "" + +#: pod/meeting/models.py +msgid "Live gateway used for this live" +msgstr "" + +#: pod/meeting/models.py +msgid "Live gateway (encoder and broadcaster) that perform the livestream" +msgstr "" + #: pod/meeting/templates/meeting/add_or_edit.html #: pod/meeting/templates/meeting/link_meeting.html pod/meeting/views.py msgid "Edit the meeting" @@ -5571,6 +5645,105 @@ msgstr "" msgid "See all my meetings" msgstr "" +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "Informations about meetings" +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"This meeting module is based on the OpenSource BigBlueButton solution." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"This solution enables voice and video image sharing, presentations with or " +"without a whiteboard, public and private chat tools, screen sharing, voice " +"over IP, online polling and the use of office documents." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "Informations about webinars" +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"If you want to hold an online conference for a large audience (over 200 " +"users), you can use the Webinar mode, accessible from this meetings module." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"This Webinar mode enables you to transmit information to a large audience " +"via a live broadcast (accessible from the platform's live page) and " +"interaction - if you wish - via an integrated chat." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Once you've saved the form, you can start the webinar by clicking on the " +"'Start the webinar' button." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Shortly after clicking the 'Start the webinar' button, the live stream will " +"be available to users on the Lives page." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"You can invite other speakers/trainers to join you in BigBlueButton. Live " +"should only be used by listeners." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Once the webinar has been created, you can modify the date and time " +"information as you wish, and the live webinar will be updated accordingly." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "This can be very useful for pre-event testing." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Once started, you can access the webinar information and additional actions " +"via " +"Show webinar informations in the meetings list." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"As you have the appropriate rights, once the webinar has been created, you " +"can access additional settings for the created event via My Events in the " +"main menu." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "Recommendations" +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "There are just a few recommendations to follow: " +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "" +"Please do not end the meeting before it is actually over." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "No recurrence available for webinars." +msgstr "" + +#: pod/meeting/templates/meeting/filter_aside_meeting.html +msgid "Remember to not use breakout rooms in this case." +msgstr "" + #: pod/meeting/templates/meeting/internal_recordings.html msgid "" "After recording a Big Blue Button meeting, recordings of that meeting will " @@ -5679,6 +5852,10 @@ msgid "" "is always available." msgstr "" +#: pod/meeting/templates/meeting/meeting_card.html +msgid "This meeting is a webinar" +msgstr "" + #: pod/meeting/templates/meeting/meeting_card.html msgid "Access to this meeting is restricted" msgstr "" @@ -5687,10 +5864,22 @@ msgstr "" msgid "This meeting is inactive" msgstr "" +#: pod/meeting/templates/meeting/meeting_card.html +msgid "Show webinar informations" +msgstr "" + +#: pod/meeting/templates/meeting/meeting_card.html +msgid "Join the webinar" +msgstr "" + #: pod/meeting/templates/meeting/meeting_card.html msgid "Show meeting informations" msgstr "" +#: pod/meeting/templates/meeting/meeting_card.html +msgid "Start the webinar" +msgstr "" + #: pod/meeting/templates/meeting/meeting_card.html msgid "Start the meeting" msgstr "" @@ -5791,6 +5980,13 @@ msgstr "" msgid "You cannot edit this meeting." msgstr "" +#: pod/meeting/views.py +msgid "" +"It is not possible to hold a webinar during this period. Webinar mode has " +"been disabled for this meeting. Please try to change the period or contact " +"the administrator." +msgstr "" + #: pod/meeting/views.py msgid "You cannot delete this meeting." msgstr "" @@ -5811,6 +6007,14 @@ msgstr "" msgid "Password given is not correct." msgstr "" +#: pod/meeting/views.py +msgid "You cannot end this meeting." +msgstr "" + +#: pod/meeting/views.py +msgid "The meeting was successfully stopped." +msgstr "" + #: pod/meeting/views.py msgid "Meeting recordings" msgstr "" @@ -5898,6 +6102,67 @@ msgstr "" msgid "Impossible to create the internal recording" msgstr "" +#: pod/meeting/views.py +msgid "You can't end this webinar live." +msgstr "" + +#: pod/meeting/views.py +msgid "You can't restart this webinar live." +msgstr "" + +#: pod/meeting/webinar.py +#, python-format +msgid "Webinar mode has been successfully started for “%s” meeting." +msgstr "" + +#: pod/meeting/webinar.py +#, python-format +msgid "Error to start webinar mode for “%s” meeting: %s" +msgstr "" + +#: pod/meeting/webinar.py +#, python-format +msgid "Webinar mode has been successfully stopped for “%s” meeting." +msgstr "" + +#: pod/meeting/webinar.py +#, python-format +msgid "Error to stop webinar mode for “%s” meeting: %s" +msgstr "" + +#: pod/meeting/webinar.py +msgid "" +"it is not possible to use a development server (localhost) for this " +"functionality." +msgstr "" + +#: pod/meeting/webinar_utils.py +msgid "Too many webinars" +msgstr "" + +#: pod/meeting/webinar_utils.py +#, python-format +msgid "" +"There are too many webinars (%s) for the number of live gateways allocated " +"(%s). The next meeting has been created but not like a webinar:%s %s [%s-" +"%s].\n" +"Please fix the problem either by increasing the number of live gateways or " +"by modifying/deleting one of the affected webinars (with the users' " +"agreement).\n" +"Other webinars: %s" +msgstr "" + +#: pod/meeting/webinar_utils.py +#, python-format +msgid "" +"

    There are too many webinars (%s) for the number of live gateways " +"allocated (%s). The next webinar has been created but not like a " +"webinar:

    • %s %s [%s-%s].

    Please fix the problem " +"either by increasing the number of live gateways or by modifying/deleting " +"one of the affected webinars (with the users' agreement).
    Other webinars: " +"%s" +msgstr "" + #: pod/playlist/apps.py pod/playlist/models.py #: pod/playlist/templates/playlist/add_or_edit.html #: pod/playlist/templates/playlist/delete.html diff --git a/pod/locale/nl/LC_MESSAGES/djangojs.po b/pod/locale/nl/LC_MESSAGES/djangojs.po index 5cf86873e7..97e27670b4 100644 --- a/pod/locale/nl/LC_MESSAGES/djangojs.po +++ b/pod/locale/nl/LC_MESSAGES/djangojs.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Esup-Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-11 20:03+0000\n" +"POT-Creation-Date: 2024-04-12 12:54+0200\n" "PO-Revision-Date: 2023-02-08 15:22+0100\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -163,11 +163,19 @@ msgstr "" msgid "A caption cannot contain more than 80 characters." msgstr "" +#: pod/completion/static/js/caption_maker.js +msgid "Add a caption/subtitle after this one" +msgstr "" + #: pod/completion/static/js/caption_maker.js #: pod/podfile/static/podfile/js/filewidget.js msgid "Add" msgstr "" +#: pod/completion/static/js/caption_maker.js +msgid "Delete this caption/subtitle" +msgstr "" + #: pod/completion/static/js/caption_maker.js #: pod/video/static/js/comment-script.js msgid "Delete" @@ -469,6 +477,18 @@ msgstr "" msgid "Unable to find information about the meeting" msgstr "" +#: pod/meeting/static/js/my_meetings.js +msgid "Restart only the live" +msgstr "" + +#: pod/meeting/static/js/my_meetings.js +msgid "End only the live" +msgstr "" + +#: pod/meeting/static/js/my_meetings.js +msgid "End the webinar (meeting and live)" +msgstr "" + #: pod/meeting/static/js/my_meetings.js msgid "End the meeting" msgstr "" @@ -512,6 +532,14 @@ msgstr "" msgid "Loading…" msgstr "" +#: pod/podfile/static/podfile/js/filewidget.js +msgid "Change image" +msgstr "" + +#: pod/podfile/static/podfile/js/filewidget.js +msgid "Change file" +msgstr "" + #: pod/podfile/static/podfile/js/filewidget.js msgid "Open file in a new tab" msgstr "" @@ -629,14 +657,37 @@ msgstr "" msgid "Cancel" msgstr "" +#: pod/video/static/js/comment-script.js +#, javascript-format +msgid "%s vote" +msgid_plural "%s votes" +msgstr[0] "" +msgstr[1] "" + #: pod/video/static/js/comment-script.js msgid "Agree with the comment" msgstr "" +#: pod/video/static/js/comment-script.js +msgid "Reply to comment" +msgstr "" + +#: pod/video/static/js/comment-script.js +msgid "Reply" +msgstr "" + #: pod/video/static/js/comment-script.js msgid "Remove this comment" msgstr "" +#: pod/video/static/js/comment-script.js +msgid "Add a public comment" +msgstr "" + +#: pod/video/static/js/comment-script.js +msgid "Send" +msgstr "" + #: pod/video/static/js/comment-script.js msgid "Show answers" msgstr "" @@ -649,13 +700,6 @@ msgstr "" msgid "Sorry, you’re not allowed to vote by now." msgstr "" -#: pod/video/static/js/comment-script.js -#, javascript-format -msgid "%s vote" -msgid_plural "%s votes" -msgstr[0] "" -msgstr[1] "" - #: pod/video/static/js/comment-script.js msgid "Sorry, you can’t comment this video by now." msgstr "" @@ -688,38 +732,47 @@ msgstr[0] "" msgstr[1] "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "This content is password protected." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "This content is chaptered." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "This content is in draft." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Video content." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Audio content." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Edit the video" msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Complete the video" msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Chapter the video" msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Delete the video" msgstr "" @@ -791,6 +844,18 @@ msgstr "" msgid "Edit the category" msgstr "" +#: pod/video/static/js/video_category.js +msgid "Delete the category" +msgstr "" + +#: pod/video/static/js/video_category.js +msgid "Success!" +msgstr "" + +#: pod/video/static/js/video_category.js +msgid "Error…" +msgstr "" + #: pod/video/static/js/video_category.js msgid "Category created successfully" msgstr "" diff --git a/pod/main/configuration.json b/pod/main/configuration.json index c1ef7c6712..869094cb43 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -1691,6 +1691,93 @@ "pod_version_end": "", "pod_version_init": "3.1" }, + "USE_MEETING_WEBINAR": { + "default_value": false, + "description": { + "en": [ + "Activate Webinar mode for the meetings module" + ], + "fr": [ + "Activation du mode Webinaire pour le module des réunions" + ] + }, + "pod_version_end": "", + "pod_version_init": "3.6.0" + }, + "MEETING_WEBINAR_SIPMEDIAGW_URL": { + "default_value": "", + "description": { + "en": [ + "URL of the SIPMediaGW server that manages webinars (e.g. https://sipmediagw.univ.fr)" + ], + "fr": [ + "URL du serveur SIPMediaGW qui gère les webinaires (Ex: https://sipmediagw.univ.fr)" + ] + }, + "pod_version_end": "", + "pod_version_init": "3.6.0" + }, + "MEETING_WEBINAR_SIPMEDIAGW_TOKEN": { + "default_value": "", + "description": { + "en": [ + "Bearer token for the SIPMediaGW server that manages webinars" + ], + "fr": [ + "Jeton bearer du serveur SIPMediaGW qui gère les webinaires" + ] + }, + "pod_version_end": "", + "pod_version_init": "3.6.0" + }, + "MEETING_WEBINAR_FIELDS": { + "default_value": "(\"is_webinar\", \"enable_chat\")", + "description": { + "en": [ + "List the additional fields for the webinar session form", + "the additional fields are displayed directly in the form page of a webinar" + ], + "fr": [ + "Permet de définir les champs complémentaires du formulaire de création d’un webinaire", + "ces champs complémentaires sont affichés directement dans la page de formulaire d’un webinaire", + "```", + "MEETING_WEBINAR_FIELDS:", + "(", + " \"is_webinar\",", + " \"enable_chat\",", + ")", + "```" + ] + }, + "pod_version_end": "", + "pod_version_init": "3.6.0" + }, + "MEETING_WEBINAR_AFFILIATION": { + "default_value": "['faculty', 'employee', 'staff']", + "description": { + "en": [ + "Access groups or affiliations of people authorized to create a webinar" + ], + "fr": [ + "Groupes d’accès ou affiliations des personnes autorisées à créer un webinaire" + ] + }, + "pod_version_end": "", + "pod_version_init": "3.6.0" + }, + "MEETING_WEBINAR_GROUP_ADMIN": { + "default_value": "webinar admin", + "description": { + "en": [ + "Group of people authorized to create a webinar" + ], + "fr": [ + "Groupe des personnes autorisées à créer un webinaire" + ] + }, + "pod_version_end": "", + "pod_version_init": "3.6.0" + }, "USE_MEETING": { "default_value": false, "description": { diff --git a/pod/main/context_processors.py b/pod/main/context_processors.py index b72b712209..b26f19c48c 100644 --- a/pod/main/context_processors.py +++ b/pod/main/context_processors.py @@ -74,6 +74,8 @@ USE_MEETING = getattr(django_settings, "USE_MEETING", False) +USE_MEETING_WEBINAR = getattr(django_settings, "USE_MEETING_WEBINAR", False) + RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY = getattr( django_settings, "RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY", False ) @@ -136,6 +138,7 @@ def context_settings(request): new_settings["USE_OPENCAST_STUDIO"] = USE_OPENCAST_STUDIO new_settings["COOKIE_LEARN_MORE"] = COOKIE_LEARN_MORE new_settings["USE_MEETING"] = USE_MEETING + new_settings["USE_MEETING_WEBINAR"] = USE_MEETING_WEBINAR new_settings["RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY"] = ( RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY ) diff --git a/pod/main/rest_router.py b/pod/main/rest_router.py index 68b9323df6..6e78f422cf 100644 --- a/pod/main/rest_router.py +++ b/pod/main/rest_router.py @@ -24,6 +24,9 @@ if getattr(settings, "USE_BBB", True): from pod.bbb import rest_views as bbb_views +if getattr(settings, "USE_MEETING", True): + from pod.meeting import rest_views as meeting_views + router = routers.DefaultRouter() router.register(r"mainfiles", main_views.CustomFileModelViewSet) @@ -72,6 +75,12 @@ router.register(r"bbb_attendee", bbb_views.AttendeeModelViewSet) router.register(r"bbb_livestream", bbb_views.LivestreamModelViewSet) +if getattr(settings, "USE_MEETING", True): + router.register(r"meeting_session", meeting_views.MeetingModelViewSet) + router.register(r"meeting_internal_recording", meeting_views.InternalRecordingModelViewSet) + router.register(r"meeting_livestream", meeting_views.LivestreamModelViewSet) + router.register(r"meeting_live_gateway", meeting_views.LiveGatewayModelViewSet) + urlpatterns = [ url(r"dublincore/$", video_views.DublinCoreView.as_view(), name="dublincore"), url( diff --git a/pod/main/test_settings.py b/pod/main/test_settings.py index 330cedcfc7..6d221e2045 100644 --- a/pod/main/test_settings.py +++ b/pod/main/test_settings.py @@ -102,6 +102,12 @@ def get_shared_secret(): XAPI_LRS_LOGIN = "" XAPI_LRS_PWD = "" +# Webinar options +USE_MEETING_WEBINAR = True +MEETING_WEBINAR_SIPMEDIAGW_URL = "https://127.0.0.1" +MEETING_WEBINAR_SIPMEDIAGW_TOKEN = "token" +MEETING_WEBINAR_AFFILIATION = ["faculty", "employee", "staff"] + # Uniquement lors d'environnement conteneurisé if USE_DOCKER: MIGRATION_MODULES = {"flatpages": "pod.db_migrations"} diff --git a/pod/meeting/admin.py b/pod/meeting/admin.py index d13ca8d8e1..b6eab1c18f 100644 --- a/pod/meeting/admin.py +++ b/pod/meeting/admin.py @@ -7,12 +7,13 @@ from django.utils.html import mark_safe from django.contrib.admin import widgets -from .models import Meeting, InternalRecording +from .models import Meeting, InternalRecording, Livestream, LiveGateway from .forms import ( MeetingForm, MEETING_MAIN_FIELDS, MEETING_DATE_FIELDS, MEETING_RECURRING_FIELDS, + MEETING_WEBINAR_FIELDS, get_meeting_fields, ) @@ -143,6 +144,7 @@ def join_url(self, obj): (None, {"fields": MEETING_MAIN_FIELDS}), (_("Date"), {"fields": MEETING_DATE_FIELDS}), (_("Recurring"), {"fields": MEETING_RECURRING_FIELDS}), + (_("Webinar options"), {"fields": MEETING_WEBINAR_FIELDS}), ( "Advanced options", { @@ -189,3 +191,40 @@ class InternalRecordingAdmin(admin.ModelAdmin): "source_url", "owner", ] + + +@admin.register(Livestream) +class LivestreamAdmin(admin.ModelAdmin): + list_display = ( + "id", + "meeting", + "live_gateway", + "event", + "status", + ) + list_display_links = ("id", "meeting") + ordering = ("-id", "meeting") + readonly_fields = [] + search_fields = [ + "id", + "meeting__meeting_name", + "meeting__owner__username", + "meeting__owner__first_name", + "meeting__owner__last_name", + ] + + +@admin.register(LiveGateway) +class LiveGatewayAdmin(admin.ModelAdmin): + list_display = ( + "id", + "rtmp_stream_url", + "broadcaster", + ) + list_display_links = ("id", "rtmp_stream_url") + ordering = ("-id", "rtmp_stream_url") + readonly_fields = [] + search_fields = [ + "id", + "broadcaster__broadcaster_name", + ] diff --git a/pod/meeting/forms.py b/pod/meeting/forms.py index 71ae162d5d..9263dca3fd 100644 --- a/pod/meeting/forms.py +++ b/pod/meeting/forms.py @@ -18,6 +18,12 @@ from django.utils.translation import ugettext_lazy as _ from pod.main.forms_utils import add_placeholder_and_asterisk from pod.main.forms_utils import OwnerWidget, AddOwnerWidget +from pod.meeting.webinar import ( + start_webinar, + stop_webinar, + toggle_rtmp_gateway +) + __FILEPICKER__ = False if getattr(settings, "USE_PODFILE", False): @@ -68,12 +74,24 @@ ("record", "auto_start_recording"), # , "allow_start_stop_recording" ) +USE_MEETING_WEBINAR = getattr(settings, "USE_MEETING_WEBINAR", False) + +MEETING_WEBINAR_FIELDS = getattr( + settings, + "MEETING_WEBINAR_FIELDS", + ( + "is_webinar", + "enable_chat", + ), +) + __MEETING_EXCLUDE_FIELDS__ = ( MEETING_MAIN_FIELDS + MEETING_DATE_FIELDS + MEETING_RECURRING_FIELDS + ("id", "start_at") + MEETING_RECORD_FIELDS + + MEETING_WEBINAR_FIELDS ) @@ -83,7 +101,8 @@ __MEETING_EXCLUDE_FIELDS__ = __MEETING_EXCLUDE_FIELDS__ + (field.name,) -def get_meeting_fields(): +def get_meeting_fields() -> list: + """Get all meeting fields.""" fields = [] for field in Meeting._meta.fields: if field.name not in __MEETING_EXCLUDE_FIELDS__: @@ -91,7 +110,8 @@ def get_meeting_fields(): return fields -def get_random_string(length): +def get_random_string(length: int) -> str: + """Get a random string, with lowercase letters.""" # choose from all lowercase letter letters = string.ascii_lowercase result_str = "".join(random.choice(letters) for i in range(length)) @@ -103,6 +123,8 @@ class MeetingForm(forms.ModelForm): required_css_class = "required" is_admin = False is_superuser = False + manage_webinar = False + is_personal = False def get_time_choices( start_time=datetime.time(0, 0, 0), @@ -211,6 +233,20 @@ def get_rounded_time(): ), ) + if USE_MEETING_WEBINAR: + fieldsets += ( + ( + "input-group-webinar", + { + "legend": ( + '' + + " %s" % _("Webinar options") + ), + "fields": MEETING_WEBINAR_FIELDS, + }, + ), + ) + fieldsets += ( ( "modal", @@ -238,6 +274,7 @@ def get_rounded_time(): ) def filter_fields_admin(form): + """Remove fields for not admin user.""" if form.is_superuser is False and form.is_admin is False: form.remove_field("owner") @@ -246,6 +283,16 @@ def filter_fields_admin(form): else: form.remove_field("days_of_week") + def filter_fields_webinar(form): + """Display webinar fields only for authorized user.""" + if ( + form.manage_webinar is False + and form.is_admin is False + and form.is_superuser is False + ) or (form.is_personal): + form.remove_field("is_webinar") + form.remove_field("enable_chat") + def clean_start_date(self): """Check two things: - the start date is before the recurrence deadline. @@ -300,6 +347,7 @@ def clean_add_owner(self): ) def clean(self): + """Clean all form fields.""" self.cleaned_data = super(MeetingForm, self).clean() if "expected_duration" in self.cleaned_data.keys(): self.cleaned_data["expected_duration"] = timezone.timedelta( @@ -338,8 +386,41 @@ def clean(self): raise ValidationError( _("Voice bridge must be a 5-digit number in the range 10000 to 99999") ) + # Manage when user has changed webinar fields + self.is_webinar_has_changed() + self.enable_chat_has_changed() + + def is_webinar_has_changed(self): + """Manage when user has changed is_webinar field and disable the webinar mode.""" + if self.instance.pk is not None and "is_webinar" in self.cleaned_data: + if self.instance.is_webinar != self.cleaned_data["is_webinar"]: + # Disable webinar mode when meeting running + if ( + self.instance.get_is_meeting_running() is True + and self.cleaned_data["is_webinar"] is False + ): + # Stop the webinar in this case + stop_webinar(self.request, self.instance.id) + # Enable webinar mode when meeting running and owner + if ( + self.instance.get_is_meeting_running() is True + and self.cleaned_data["is_webinar"] is True + and self.instance.owner == self.request.user + ): + # Start the webinar in this case + start_webinar(self.request, self.instance.id) + + def enable_chat_has_changed(self): + """Manage when user has changed enable_chat field.""" + if self.instance.pk is not None and "enable_chat" in self.cleaned_data: + if self.instance.enable_chat != self.cleaned_data["enable_chat"]: + if self.instance.get_is_meeting_running() is True: + # When enable chat field changed, send a toggle request + # to SIPMediaGW if webinar already started + toggle_rtmp_gateway(self.instance.id) def __init__(self, *args, **kwargs): + self.request = kwargs.pop('request', None) self.is_staff = ( kwargs.pop("is_staff") if "is_staff" in kwargs.keys() else self.is_staff ) @@ -351,10 +432,14 @@ def __init__(self, *args, **kwargs): ) self.current_lang = kwargs.pop("current_lang", settings.LANGUAGE_CODE) self.current_user = kwargs.pop("current_user", None) + self.manage_webinar = kwargs.pop("manage_webinar", False) + self.is_personal = kwargs.pop("is_personal", False) super(MeetingForm, self).__init__(*args, **kwargs) + self.set_queryset() self.filter_fields_admin() self.date_time_duration() + self.filter_fields_webinar() # Manage required fields html self.fields = add_placeholder_and_asterisk(self.fields) @@ -367,7 +452,7 @@ def __init__(self, *args, **kwargs): self.instance, "weekdays", None ): self.initial["days_of_week"] = list(self.instance.weekdays) - # remove recurring until value if recurrence is None + # Remove recurring until value if recurrence is None if ( getattr(self.instance, "id", None) and getattr(self.instance, "recurrence", None) is None @@ -391,7 +476,12 @@ def manage_personal_meeting_room(self): # Name is a readonly field in such a case self.fields["name"].widget.attrs["readonly"] = True # Hide time settings - hidden_fields = ("start", "start_time", "expected_duration", "is_personal") + hidden_fields = ( + "start", + "start_time", + "expected_duration", + "is_personal", + ) for field in hidden_fields: self.hide_field(field) diff --git a/pod/meeting/models.py b/pod/meeting/models.py index bed271b07f..782e0a4b7c 100644 --- a/pod/meeting/models.py +++ b/pod/meeting/models.py @@ -34,6 +34,7 @@ from pod.authentication.models import AccessGroup from pod.main.models import get_nextautoincrement +from pod.live.models import Broadcaster, Event from .utils import ( api_call, @@ -57,6 +58,7 @@ MEETING_DISABLE_RECORD = getattr(settings, "MEETING_DISABLE_RECORD", True) STATIC_ROOT = getattr(settings, "STATIC_ROOT", "") TEST_SETTINGS = getattr(settings, "TEST_SETTINGS", False) +USE_MEETING_WEBINAR = getattr(settings, "USE_MEETING_WEBINAR", False) SITE_ID = getattr(settings, "SITE_ID", 1) @@ -105,7 +107,7 @@ # "lockSettingsLockOnJoin", # "lockSettingsLockOnJoinConfigurable", # "lockSettingsHideViewersCursor", - # "guestPolicy", + "guestPolicy": "guest_policy", # "meetingKeepEvents", # "endWhenNoModerator", # "endWhenNoModeratorDelayInMinutes", @@ -286,7 +288,7 @@ class Meeting(models.Model): # #################### Configs max_participants = models.IntegerField( - default=100, verbose_name=_("Max Participants") + default=150, verbose_name=_("Max Participants") ) welcome_text = models.TextField( default=_("Welcome!"), verbose_name=_("Meeting Text in Bigbluebutton") @@ -321,6 +323,20 @@ class Meeting(models.Model): verbose_name=_("Allow Stop/Start Recording"), help_text=_("Allow the user to start/stop recording. (default true)"), ) + # #################### Guest policy for the meeting + GUEST_POLICY = ( + ("ALWAYS_ACCEPT", _("Always accept")), + ("ALWAYS_DENY", _("Always deny")), + ("ASK_MODERATOR", _("Ask moderator")), + ) + guest_policy = models.CharField( + null=True, + blank=True, + choices=GUEST_POLICY, + max_length=50, + verbose_name=_("Guest policy"), + help_text=_("Will set the guest policy for the meeting."), + ) # #################### Lock settings lock_settings_disable_cam = models.BooleanField( @@ -397,6 +413,30 @@ class Meeting(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # #################### WEBINAR PART + # Webinar mode + is_webinar = models.BooleanField( + verbose_name=_("Webinar mode"), + help_text=_( + "Do you want to start this meeting as a webinar? In such a case, " + "you can invite presenters to join you in BigBlueButton, and listeners " + "will have direct access to a livestream in the livestreams page. " + ), + default=False, + ) + + # If the user wants that students have a chat in the live page + enable_chat = models.BooleanField( + verbose_name=_("Enable chat"), + help_text=_( + "Do you want a chat on the live page " + "for listeners? Messages sent in this live page's chat will " + "end up in BigBlueButton's public chat. " + "This public chat will be also displayed in the live." + ), + default=False, + ) + def __str__(self): return "{}-{}".format("%04d" % self.id, self.name) @@ -468,7 +508,7 @@ def check_recurrence(self): # noqa: C901 self.reset_recurrence() def save(self, *args, **kwargs): - """Store a video object in db.""" + """Store a meeting object in db.""" self.check_recurrence() newid = -1 if not self.id: @@ -1028,6 +1068,7 @@ class Meta: @receiver(pre_save, sender=Meeting) def default_site_meeting(sender, instance, **kwargs): + """Presave method for a meeting.""" if not hasattr(instance, "site"): instance.site = Site.objects.get_current() if instance.recurring_until and instance.start > instance.recurring_until: @@ -1148,3 +1189,99 @@ def default_site_recording(sender, instance, **kwargs): """Save default site for this recording.""" if not hasattr(instance, "site"): instance.site = Site.objects.get_current() + + +class LiveGateway(models.Model): + """Hold information about live gateways, encoders and broadcasters informations. + + Useful for BigBlueButton livestreams.""" + + # RTMP Stream URL + # Format, without authentication : rtmp://rtmpserver.univ.fr:port/application/name + # Format, with authentication : rtmp://user@password:rtmpserver.univ.fr:port/application/name.m3u8 + rtmp_stream_url = models.CharField( + _("URL of the RTMP stream"), + max_length=200, + help_text=_("Example format: rtmp://live.univ.fr/live/name"), + ) + # Broadcaster in charge to perform the live + broadcaster = models.ForeignKey( + Broadcaster, + on_delete=models.CASCADE, + verbose_name=_("Broadcaster"), + help_text=_("Broadcaster in charge to perform lives."), + ) + + # LiveGateway's site + site = models.ForeignKey( + Site, verbose_name=_("Site"), on_delete=models.CASCADE, default=SITE_ID + ) + + def __unicode__(self): + return "%s - %s" % (self.rtmp_stream_url, self.broadcaster) + + def __str__(self): + return "%s - %s" % (self.rtmp_stream_url, self.broadcaster) + + def save(self, *args, **kwargs): + super(LiveGateway, self).save(*args, **kwargs) + + class Meta: + verbose_name = _("Live gateway") + verbose_name_plural = _("Live gateways") + + +@receiver(pre_save, sender=LiveGateway) +def default_site_livegateway(sender, instance, **kwargs): + """Save default site for this live gateway.""" + if not hasattr(instance, "site"): + instance.site = Site.objects.get_current() + + +class Livestream(models.Model): + """Hold information about BigBlueButton/Webinar livestream.""" + + # Meeting + meeting = models.ForeignKey( + Meeting, + on_delete=models.CASCADE, + verbose_name=_("Meeting") + ) + # Live status + STATUS = ( + (0, _("Live not started")), + (1, _("Live in progress")), + (2, _("Live stopped")), + ) + status = models.IntegerField(_("Live status"), choices=STATUS, default=0) + + # Live event + event = models.ForeignKey( + Event, + # We delete the livestream when delete the linked event + on_delete=models.CASCADE, + verbose_name=_("Event managed for this live"), + help_text=_("Live event for this livestream"), + ) + + # Live gateway that manage this stream + live_gateway = models.ForeignKey( + LiveGateway, + on_delete=models.CASCADE, + verbose_name=_("Live gateway used for this live"), + help_text=_("Live gateway (encoder and broadcaster) that perform the livestream"), + ) + + def __unicode__(self): + return "%s - %s" % (self.meeting, self.status) + + def __str__(self): + return "%s - %s" % (self.meeting, self.status) + + def save(self, *args, **kwargs): + super(Livestream, self).save(*args, **kwargs) + + class Meta: + verbose_name = _("Livestream") + verbose_name_plural = _("Livestreams") + ordering = ["id"] diff --git a/pod/meeting/rest_views.py b/pod/meeting/rest_views.py new file mode 100644 index 0000000000..a6c03462b7 --- /dev/null +++ b/pod/meeting/rest_views.py @@ -0,0 +1,79 @@ +"""REST API for the Meeting module.""" +from rest_framework import serializers, viewsets +from .models import InternalRecording, LiveGateway, Livestream, Meeting + + +class MeetingModelSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Meeting + fields = ( + "id", + "name", + "meeting_id", + "start_at", + "created_at", + "owner_id", + "is_personal", + "is_webinar", + "site_id", + ) + + +class MeetingModelViewSet(viewsets.ModelViewSet): + queryset = Meeting.objects.all() + serializer_class = MeetingModelSerializer + + +class InternalRecordingModelSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = InternalRecording + fields = ( + "id", + "name", + "start_at", + "recording_id", + "meeting", + "site_id" + ) + + +class InternalRecordingModelViewSet(viewsets.ModelViewSet): + queryset = InternalRecording.objects.all() + serializer_class = InternalRecordingModelSerializer + + +class LivestreamModelSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Livestream + fields = ( + "id", + "meeting", + "status", + "event", + "live_gateway_id" + ) + filter_fields = ("status") + + +class LivestreamModelViewSet(viewsets.ModelViewSet): + queryset = Livestream.objects.all() + serializer_class = LivestreamModelSerializer + filterset_fields = ["status"] + + +class LiveGatewayModelSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = LiveGateway + fields = ( + "id", + "rtmp_stream_url", + "broadcaster_id", + "site_id", + ) + filter_fields = ("site_id") + + +class LiveGatewayModelViewSet(viewsets.ModelViewSet): + queryset = LiveGateway.objects.all() + serializer_class = LiveGatewayModelSerializer + filterset_fields = ["site_id"] diff --git a/pod/meeting/static/css/meeting.css b/pod/meeting/static/css/meeting.css index e72c7fff83..28a4ea8a17 100644 --- a/pod/meeting/static/css/meeting.css +++ b/pod/meeting/static/css/meeting.css @@ -2,7 +2,7 @@ * Esup-Pod Meeting styles */ -.meeting-card-inactive { + .meeting-card-inactive { border: 1px solid #f5a391; } @@ -129,3 +129,12 @@ div.alert .proposition::before { font-family: bootstrap-icons; vertical-align: -0.125em; } + +/* Max 16rem for meeting card */ +.pod-infinite-container { + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); +} + +.meeting-nowrap { + white-space: nowrap !important; +} diff --git a/pod/meeting/static/js/my_meetings.js b/pod/meeting/static/js/my_meetings.js index 30d0fe78fe..be68d26e3a 100644 --- a/pod/meeting/static/js/my_meetings.js +++ b/pod/meeting/static/js/my_meetings.js @@ -5,10 +5,13 @@ meetingModal.addEventListener("show.bs.modal", function (event) { // Button that triggered the modal const button = event.relatedTarget; // Extract info from data-bs-* attributes - const meeting_id = button.getAttribute("data-bs-meeting-id"); + const meetingId = button.getAttribute("data-bs-meeting-id"); const title = button.getAttribute("data-bs-meeting-title"); - const endurl = button.getAttribute("data-bs-meeting-end-url"); + const endUrl = button.getAttribute("data-bs-meeting-end-url"); const modalHref = button.getAttribute("data-bs-meeting-info-url"); + const isWebinar = (button.getAttribute("data-bs-meeting-webinar") == "True"); + const endLiveUrl = button.getAttribute("data-bs-meeting-end-live-url"); + const restartLiveUrl = button.getAttribute("data-bs-meeting-restart-live-url"); fetch(modalHref, { method: "GET", @@ -24,13 +27,28 @@ meetingModal.addEventListener("show.bs.modal", function (event) { ); console.log(msg); } else { - const modalendlink = - '

    ' + - gettext("End the meeting") + - "

    "; - modalBody.innerHTML = generateHtml(data.info) + modalendlink; + // All buttons + var allLinks = ""; + if (isWebinar) { + // Buttons for webinar + const modalRestartLiveLink = '

    ' + gettext("Restart only the live") + '

    '; + const modalEndLiveLink = '

    ' + gettext("End only the live") + '

    '; + const modalEndLink = '

    ' + gettext("End the webinar (meeting and live)") + '

    '; + allLinks = modalRestartLiveLink + modalEndLiveLink + modalEndLink; + } else { + // Buttons for standard meeting + const modalEndLink = '

    ' + gettext("End the meeting") + '

    '; + allLinks = modalEndLink; + } + modalBody.innerHTML = + '
    ' + + '
    ' + + generateHtml(data.info) + + '
    ' + + '
    ' + + allLinks + + '
    ' + + '
    '; } }) .catch((error) => { @@ -44,8 +62,8 @@ meetingModal.addEventListener("show.bs.modal", function (event) { //const modalFooterEndLink = meetingModal.querySelector('.modal-footer a.endlink') modalTitle.textContent = title; - modalBody.textContent = meeting_id; - //modalFooterEndLink.setAttribute("href", endurl) + modalBody.textContent = meetingId; + //modalFooterEndLink.setAttribute("href", endUrl) }); /* TODO: check if level parameter can be removed. */ @@ -92,3 +110,10 @@ function copyValue(value) { showalert(gettext("Something went wrong."), "alert-danger"); }); } + +/** + * Display a loading cursor. + */ +function displayLoader() { + document.body.style.cursor = 'wait'; +} diff --git a/pod/meeting/templates/meeting/add_or_edit.html b/pod/meeting/templates/meeting/add_or_edit.html index 9d4e721278..fe2fa2f217 100644 --- a/pod/meeting/templates/meeting/add_or_edit.html +++ b/pod/meeting/templates/meeting/add_or_edit.html @@ -154,8 +154,13 @@ {% endif %} {% endblock page_content %} -{% block collapse_page_aside %}{% endblock collapse_page_aside %} -{% block page_aside %}{% endblock page_aside %} +{% block collapse_page_aside %} + {{ block.super }} +{% endblock collapse_page_aside %} + +{% block page_aside %} + {% include 'meeting/filter_aside_meeting.html' %} +{% endblock page_aside %} {% block more_script %} @@ -276,10 +281,10 @@ /* Manage personal meeting room */ var is_personal = ('{{ form.instance.is_personal }}' == "True"); -var time_fieldset = document.getElementById('see_recurring_fields').parentElement.parentElement.parentElement; +var time_fieldset = document.getElementById('see_recurring_fields').parentElement.parentElement.parentElement; if (is_personal && time_fieldset){ - // Hide the time fieldset - time_fieldset.classList.add("d-none"); + // Hide the time fieldset + time_fieldset.classList.add("d-none"); } /* Manage when voice bridge is modified in a bad format, after a BBB session start */ @@ -291,5 +296,41 @@ field_voice_bridge.value = new_voice_bridge; } } + +/* Manage webinars */ +document.addEventListener("DOMContentLoaded", function (event) { + var webinar_input_group = document.querySelectorAll('[id^=meeting_form_input-group-webinar]') + var is_webinar = document.querySelector('input[name=is_webinar]'); + let see_recurring_fields = document.getElementById('see_recurring_fields'); + let field_guest_policy = document.getElementById('id_guest_policy'); + let field_enable_chat = document.getElementById('id_enable_chat'); + // Manage webinar mode : no recurring in such a case and guest policy = Always accept + if (is_webinar && see_recurring_fields && field_guest_policy) { + if (is_webinar.checked) { + see_recurring_fields.classList.add("d-none"); + field_guest_policy.value = "ALWAYS_ACCEPT"; + field_guest_policy.disabled = true; + } + is_webinar.addEventListener('change', function (event) { + if (is_webinar.checked) { + // No recurrence available for webinars + see_recurring_fields.classList.add("d-none"); + field_guest_policy.disabled = true; + } else { + // Recurrence + see_recurring_fields.classList.remove("d-none"); + field_guest_policy.disabled = false; + // No chat options for a normal meeting + if (field_enable_chat) { + field_enable_chat.checked = false; + } + } + }); + } + // Do not display legend when no webinar fields + if (!is_webinar && webinar_input_group) { + webinar_input_group[0].classList.add("d-none"); + } +}); {% endblock more_script %} diff --git a/pod/meeting/templates/meeting/filter_aside_meeting.html b/pod/meeting/templates/meeting/filter_aside_meeting.html new file mode 100644 index 0000000000..1e66fcb3f8 --- /dev/null +++ b/pod/meeting/templates/meeting/filter_aside_meeting.html @@ -0,0 +1,53 @@ +{% load i18n %} +{% load custom_tags %} + +{% spaceless %} +
    +

    {% trans "Informations about meetings" %}

    +
    +

    {% trans "This meeting module is based on the OpenSource BigBlueButton solution." %}

    +

    {% trans "This solution enables voice and video image sharing, presentations with or without a whiteboard, public and private chat tools, screen sharing, voice over IP, online polling and the use of office documents." %}

    +
    +
    + + {% if manage_webinar %} +
    +

    {% trans "Informations about webinars" %}

    +
    +

    {% trans "If you want to hold an online conference for a large audience (over 200 users), you can use the Webinar mode, accessible from this meetings module." %}

    +

    {% trans "This Webinar mode enables you to transmit information to a large audience via a live broadcast (accessible from the platform's live page) and interaction - if you wish - via an integrated chat." %}

    +

    {% trans "Once you've saved the form, you can start the webinar by clicking on the 'Start the webinar' button." %}

    +

    {% trans "Shortly after clicking the 'Start the webinar' button, the live stream will be available to users on the Lives page." %}

    +

    {% trans "You can invite other speakers/trainers to join you in BigBlueButton. Live should only be used by listeners." %}

    +
    +
    + +
    +

    {% trans "Useful tips"%}

    +
    +

    + {% trans "Once the webinar has been created, you can modify the date and time information as you wish, and the live webinar will be updated accordingly." %}
    + {% trans "This can be very useful for pre-event testing." %} +

    +

    + {% trans "Once started, you can access the webinar information and additional actions via Show webinar informations in the meetings list." %} +

    + {% if manage_event %} +

    {% trans "As you have the appropriate rights, once the webinar has been created, you can access additional settings for the created event via My Events in the main menu." %}

    + {% endif%} +
    +
    + +
    +

    {% trans "Recommendations"%}

    +
    +

    {% trans "There are just a few recommendations to follow: " %}

    +
      +
    1. {% trans "Please do not end the meeting before it is actually over." %}
    2. +
    3. {% trans "No recurrence available for webinars." %}
    4. +
    5. {% trans "Remember to not use breakout rooms in this case." %}
    6. +
    +
    +
    + {% endif %} +{% endspaceless %} diff --git a/pod/meeting/templates/meeting/meeting_card.html b/pod/meeting/templates/meeting/meeting_card.html index 9093e84904..44a5ccee00 100644 --- a/pod/meeting/templates/meeting/meeting_card.html +++ b/pod/meeting/templates/meeting/meeting_card.html @@ -4,7 +4,7 @@
    - + {{meeting.name|capfirst|truncatechars:43}}
    {% if meeting.is_personal %} {% if meeting.owner != request.user %} @@ -30,13 +30,18 @@ {% endif%} {% endif %} + {% if meeting.is_webinar %} + + + + {% endif %} {% if meeting.is_restricted %} {% endif %} {% if not meeting.is_active %} - + {% endif %} @@ -56,19 +61,40 @@ {% endif %} {% if meeting.get_is_meeting_running %} - - - - - - {% trans "Join the meeting" %} - +
    + {% if meeting.is_webinar %} + + + + + {% trans "Join the webinar" %} + + {% else %} + + + + + {% trans "Join the meeting" %} + + {% endif %} +
    {% else %} - - {% trans "Start the meeting" %} + {% if meeting.is_webinar %} + {% trans "Start the webinar" as start %} + {% else %} + {% trans "Start the meeting" as start %} + {% endif %} + + {{ start }} {% endif %}
    diff --git a/pod/meeting/tests/test_utils.py b/pod/meeting/tests/test_utils.py new file mode 100644 index 0000000000..5055905fd9 --- /dev/null +++ b/pod/meeting/tests/test_utils.py @@ -0,0 +1,106 @@ +"""Tests utils for meeting module, useful for webinar.""" + +from ..models import Meeting +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.test import TestCase +from pod.authentication.models import AccessGroup +from pod.live.models import Building, Broadcaster +from pod.meeting.models import LiveGateway +from pod.meeting.webinar import ( + chat_rtmp_gateway, + start_webinar_livestream, + stop_webinar_livestream, + toggle_rtmp_gateway +) +from pod.meeting.webinar_utils import manage_webinar +from pod.video.models import Type + + +class MeetingTestUtils(TestCase): + """Meetings utils tests list. + + Args: + TestCase (class): test case + """ + + def setUp(self): + site = Site.objects.get(id=1) + AccessGroup.objects.create(code_name="faculty", display_name="Group Faculty") + # User with faculty affiliation + user_faculty = User.objects.create( + username="pod_faculty", password="pod1234pod", email="pod@univ.fr" + ) + user_faculty.owner.auth_type = "CAS" + user_faculty.owner.affiliation = "faculty" + user_faculty.owner.sites.add(Site.objects.get_current()) + user_faculty.owner.accessgroup_set.add(AccessGroup.objects.get(code_name="faculty")) + user_faculty.owner.save() + + Meeting.objects.create( + id=1, + name="webinar_faculty", + owner=user_faculty, + site=site, + is_webinar=True, + ) + + # Default event type + Type.objects.create(title="type1") + + # Create a broadcaster + building = Building.objects.create(name="building1") + broadcaster = Broadcaster.objects.create( + name="broadcaster1", + url="http://test.live", + status=True, + enable_add_event=True, + is_restricted=True, + building=building, + ) + # Create a live gateway + LiveGateway.objects.create( + id=1, + rtmp_stream_url="rtmp://127.0.0.1:1935/live/sipmediagw", + broadcaster=broadcaster, + site=site + ) + + print(" ---> SetUp of MeetingTestUtils: OK!") + + def test_sipmediagw_commands1(self): + """Check start and stop SIPMediaGW commands (on 127.0.0.1, so management of exceptions).""" + meeting = Meeting.objects.get(id=1) + live_gateway = LiveGateway.objects.get(id=1) + # Manage the livestream and the event when the webinar is created + manage_webinar(meeting, True, live_gateway) + # Start + try: + start_webinar_livestream("https://127.0.0.1", 1) + except ValueError as ve: + self.assertTrue("/start?room=" in str(ve)) + self.assertTrue("&domain=https%3A%2F%2F127.0.0.1%2Fmeeting%2F0001-webinar_faculty" in str(ve)) + # Stop + try: + stop_webinar_livestream(1, True) + except ValueError as ve: + self.assertTrue("/stop?room=" in str(ve)) + print(" ---> test_sipmediagw_commands1 of MeetingTestUtils: OK!") + + def test_sipmediagw_commands2(self): + """Check chat and toggle SIPMediaGW commands (on 127.0.0.1, so management of exceptions).""" + meeting = Meeting.objects.get(id=1) + live_gateway = LiveGateway.objects.get(id=1) + # Manage the livestream and the event when the webinar is created + manage_webinar(meeting, True, live_gateway) + # Toggle + try: + toggle_rtmp_gateway(1) + except Exception as e: + self.assertTrue("&toggle=True" in str(e)) + # Chat + try: + chat_rtmp_gateway(1, "Message") + except Exception as e: + self.assertTrue("/chat" in str(e)) + print(" ---> test_sipmediagw_commands2 of MeetingTestUtils: OK!") diff --git a/pod/meeting/tests/test_views.py b/pod/meeting/tests/test_views.py index 59ba7cc87f..1f6223883b 100644 --- a/pod/meeting/tests/test_views.py +++ b/pod/meeting/tests/test_views.py @@ -16,6 +16,9 @@ from http import HTTPStatus from importlib import reload from pod.authentication.models import AccessGroup +from pod.live.models import Building, Broadcaster +from pod.meeting.models import LiveGateway +from pod.video.models import Type VIDEO_TEST = getattr(settings, "VIDEO_TEST", "pod/main/static/video_test/pod.mp4") @@ -209,6 +212,229 @@ def test_meeting_edit_post_request(self): print(" ---> test_meeting_edit_post_request of MeetingEditTestView: OK!") +class MeetingWebinarTestView(TestCase): + """List of tests for useful views for a webinar. + + Args: + TestCase (class): test case + """ + + def setUp(self): + site = Site.objects.get(id=1) + AccessGroup.objects.create(code_name="faculty", display_name="Group Faculty") + user = User.objects.create(username="pod", password="pod1234pod") + # User with faculty affiliation + user_faculty = User.objects.create( + username="pod_faculty", password="pod1234pod", email="pod@univ.fr" + ) + user_faculty.owner.auth_type = "CAS" + user_faculty.owner.affiliation = "faculty" + user_faculty.owner.sites.add(Site.objects.get_current()) + user_faculty.owner.accessgroup_set.add(AccessGroup.objects.get(code_name="faculty")) + user_faculty.owner.save() + + Meeting.objects.create( + id=1, + name="webinar", + owner=user, + site=site, + is_webinar=True + ) + Meeting.objects.create( + id=2, + name="webinar_faculty", + owner=user_faculty, + site=site, + is_webinar=True + ) + + user.owner.sites.add(Site.objects.get_current()) + user.owner.save() + + # Default event type + Type.objects.create(title="type1") + + # Create a broadcaster + building = Building.objects.create(name="building1") + broadcaster = Broadcaster.objects.create( + name="broadcaster1", + url="http://test.live", + status=True, + enable_add_event=True, + is_restricted=True, + building=building, + ) + # Create a live gateway + LiveGateway.objects.create( + id=1, + rtmp_stream_url="rtmp://localhost:1935/live/sipmediagw", + broadcaster=broadcaster, + site=site + ) + + print(" ---> SetUp of MeetingWebinarTestView: OK!") + + def test_meeting_webinar_add_edit1_get_request(self): + self.client = Client() + url = reverse("meeting:add", kwargs={}) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.user = User.objects.get(username="pod") + self.client.force_login(self.user) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["form"].instance.id, None) + self.assertEqual(response.context["form"].current_user, self.user) + meeting = Meeting.objects.get(name="webinar") + url = reverse("meeting:edit", kwargs={"meeting_id": meeting.meeting_id}) + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.context["form"].instance, meeting) + print(" ---> test_meeting_webinar_add_edit1_get_request of MeetingWebinarTestView: OK!") + + def test_meeting_webinar_add_edit2_get_request(self): + self.client = Client() + url = reverse("meeting:add", kwargs={}) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.user = User.objects.get(username="pod_faculty") + self.client.force_login(self.user) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["form"].instance.id, None) + self.assertEqual(response.context["form"].current_user, self.user) + meeting = Meeting.objects.get(name="webinar_faculty") + url = reverse("meeting:edit", kwargs={"meeting_id": meeting.meeting_id}) + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.context["form"].instance, meeting) + print(" ---> test_meeting_webinar_add_edit2_get_request of MeetingWebinarTestView: OK!") + + def test_meeting_webinar_add1_post_request(self): + self.client = Client() + self.user = User.objects.get(username="pod") + self.client.force_login(self.user) + nb_meeting = Meeting.objects.all().count() + url = reverse("meeting:add", kwargs={}) + response = self.client.post( + url, + { + "name": "webinar1", + }, + follow=True, + ) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertTrue(response.context["form"].errors) + response = self.client.post( + url, + { + "name": "webinar1", + "voice_bridge": 70000 + random.randint(0, 9999), + "attendee_password": "1234", + "start": "2022-08-26", + "start_time": "21:00:00", + "expected_duration": "2", + "frequency": "1", + "monthly_type": "date_day", + "max_participants": 100, + "welcome_text": "Hello", + "is_webinar": True + }, + follow=True, + ) + self.assertTrue(b"The changes have been saved." in response.content) + # check if meeting has been updated + m = Meeting.objects.get(name="webinar1") + self.assertEqual(m.attendee_password, "1234") + # Also includes personal meeting room + self.assertEqual(Meeting.objects.all().count(), nb_meeting + 2) + self.assertEqual(m.start_at, timezone.make_aware(datetime(2022, 8, 26, 21, 0, 0))) + print(" ---> test_meeting_webinar_add1_post_request of MeetingWebinarTestView: OK!") + + def test_meeting_webinar_add2_post_request(self): + self.client = Client() + self.user = User.objects.get(username="pod_faculty") + self.client.force_login(self.user) + nb_meeting = Meeting.objects.all().count() + url = reverse("meeting:add", kwargs={}) + response = self.client.post( + url, + { + "name": "webinar_faculty1", + }, + follow=True, + ) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertTrue(response.context["form"].errors) + response = self.client.post( + url, + { + "name": "webinar_faculty1", + "voice_bridge": 70000 + random.randint(0, 9999), + "attendee_password": "1234", + "start": "2022-08-26", + "start_time": "21:00:00", + "expected_duration": "2", + "frequency": "1", + "monthly_type": "date_day", + "max_participants": 100, + "welcome_text": "Hello", + "is_webinar": True + }, + follow=True, + ) + # self.assertTrue(b"It is not possible to hold a webinar during this period" in response.content) + self.assertTrue(b"The changes have been saved." in response.content) + # check if meeting has been updated + m = Meeting.objects.get(name="webinar_faculty1") + self.assertEqual(m.attendee_password, "1234") + # Also includes personal meeting room + self.assertEqual(Meeting.objects.all().count(), nb_meeting + 2) + self.assertEqual(m.start_at, timezone.make_aware(datetime(2022, 8, 26, 21, 0, 0))) + print(" ---> test_meeting_webinar_add2_post_request of MeetingWebinarTestView: OK!") + + def test_meeting_webinar_edit_post_request(self): + self.client = Client() + meeting = Meeting.objects.get(name="webinar") + url = reverse("meeting:edit", kwargs={"meeting_id": meeting.meeting_id}) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.user = User.objects.get(username="pod") + self.client.force_login(self.user) + response = self.client.post( + url, + { + "name": "webinar1", + }, + follow=True, + ) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertTrue(response.context["form"].errors) + response = self.client.post( + url, + { + "name": "webinar1", + "voice_bridge": 70000 + random.randint(0, 9999), + "attendee_password": "1234", + "start": "2022-08-26", + "start_time": "14:00:00", + "expected_duration": "2", + "frequency": "1", + "monthly_type": "date_day", + "max_participants": 100, + "welcome_text": "Hello", + "is_webinar": True + }, + follow=True, + ) + self.assertTrue(b"The changes have been saved." in response.content) + # check if meeting has been updated + m = Meeting.objects.get(name="webinar1") + self.assertEqual(m.attendee_password, "1234") + self.assertEqual(m.start_at, timezone.make_aware(datetime(2022, 8, 26, 14, 0, 0))) + print(" ---> test_meeting_webinar_edit1_post_request of MeetingWebinarTestView: OK!") + + class MeetingDeleteTestView(TestCase): """List of tests for deleting views from a meeting. diff --git a/pod/meeting/urls.py b/pod/meeting/urls.py index bf9192659b..47088f3258 100644 --- a/pod/meeting/urls.py +++ b/pod/meeting/urls.py @@ -1,5 +1,6 @@ """URLs for Meeting module.""" +from django.conf.urls import url from django.urls import path from . import views @@ -47,6 +48,17 @@ path("recording_ready/", views.recording_ready, name="recording_ready"), ] +if views.USE_MEETING_WEBINAR: + urlpatterns += [ + path("restart_live//", views.restart_live, name="restart_live"), + path("end_live//", views.end_live, name="end_live"), + url( + r"^live_publish_chat/(?P[\d]+)/$", + views.live_publish_chat, + name="live_publish_chat", + ), + ] + urlpatterns += [ path("/", views.join, name="join"), path("/", views.join, name="join"), diff --git a/pod/meeting/utils.py b/pod/meeting/utils.py index 5f276eb225..066ed6d027 100644 --- a/pod/meeting/utils.py +++ b/pod/meeting/utils.py @@ -1,15 +1,13 @@ """Utils for Meeting module.""" +import bleach from datetime import date, timedelta from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.utils.translation import ugettext_lazy as _ -from pod.main.views import TEMPLATE_VISIBLE_SETTINGS from hashlib import sha1 +from pod.main.views import TEMPLATE_VISIBLE_SETTINGS -import bleach - -BBB_API_URL = getattr(settings, "BBB_API_URL", "") BBB_SECRET_KEY = getattr(settings, "BBB_SECRET_KEY", "") DEBUG = getattr(settings, "DEBUG", True) diff --git a/pod/meeting/views.py b/pod/meeting/views.py index a08f5105f4..d59d3a12ab 100644 --- a/pod/meeting/views.py +++ b/pod/meeting/views.py @@ -1,25 +1,34 @@ """Views of the Meeting module.""" import bleach +import json import jwt import logging import os import requests +import time import traceback from .forms import MeetingForm, MeetingDeleteForm, MeetingPasswordForm from .forms import MeetingInviteForm, get_random_string -from .models import Meeting, InternalRecording +from .models import Meeting, InternalRecording, Livestream from .utils import get_nth_week_number, send_email_recording_ready from datetime import datetime from django.conf import settings from django.contrib import messages -from django.contrib.auth.decorators import login_required +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import SuspiciousOperation from django.core.exceptions import PermissionDenied +from django.core.handlers.wsgi import WSGIRequest from django.core.mail import EmailMultiAlternatives -from django.http import JsonResponse, HttpResponse, HttpResponseBadRequest +from django.http import ( + HttpResponse, + HttpResponseBadRequest, + JsonResponse, + HttpResponseNotAllowed, +) from django.shortcuts import get_object_or_404 from django.shortcuts import render, redirect from django.templatetags.static import static @@ -34,6 +43,15 @@ from pod.import_video.utils import save_video, secure_request_for_upload from pod.main.views import in_maintenance, TEMPLATE_VISIBLE_SETTINGS from pod.main.utils import secure_post_request, display_message_with_icon +from pod.meeting.webinar import ( + chat_rtmp_gateway, + start_webinar, + stop_webinar +) +from pod.meeting.webinar_utils import search_for_available_livegateway, manage_webinar +from pod.live.models import Event +from pod.live.views import can_manage_event + RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY = getattr( settings, "RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY", False @@ -95,11 +113,26 @@ else "Pod" ) +USE_MEETING = getattr(settings, "USE_MEETING", False) +USE_MEETING_WEBINAR = getattr(settings, "USE_MEETING_WEBINAR", False) +MEETING_WEBINAR_AFFILIATION = getattr( + settings, + "MEETING_WEBINAR_AFFILIATION", + ("faculty", "employee", "staff") +) +MEETING_WEBINAR_GROUP_ADMIN = getattr( + settings, + "MEETING_WEBINAR_GROUP_ADMIN", + "meeting webinar admin" +) + +DEFAULT_EVENT_TYPE_ID = getattr(settings, "DEFAULT_EVENT_TYPE_ID", 1) + log = logging.getLogger(__name__) @login_required(redirect_field_name="referrer") -def my_meetings(request): +def my_meetings(request: WSGIRequest) -> HttpResponse: """List the meetings.""" site = get_current_site(request) if RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False: @@ -117,9 +150,9 @@ def my_meetings(request): meetings = [ meeting for meeting in ( - request.user.owner_meeting.all().filter(site=site) - | request.user.owners_meetings.all() - .filter(site=site) + request.user.owner_meeting.all().filter( + site=site + ) | request.user.owners_meetings.all().filter(site=site) .order_by("-is_personal", "-start_at") ) if meeting.is_active @@ -136,15 +169,13 @@ def my_meetings(request): ) -def manage_personal_meeting_room(request): - """Create, if necessary, the personal meeting room for this user. - - Args: - request (Request): HTTP request - """ +def manage_personal_meeting_room(request: WSGIRequest): + """Create, if necessary, the personal meeting room for this user.""" site = get_current_site(request) personal_meeting_room = Meeting.objects.filter( - owner=request.user, site=site, is_personal=True + owner=request.user, + site=site, + is_personal=True ).first() if not personal_meeting_room: @@ -157,14 +188,14 @@ def manage_personal_meeting_room(request): moderator_password=get_random_string(8), start_at=datetime.now().replace(minute=0, second=0, microsecond=0), recurrence=None, - is_personal=True, + is_personal=True ) @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def add_or_edit(request, meeting_id=None): +def add_or_edit(request: WSGIRequest, meeting_id=None) -> HttpResponse: """Add or edit a meeting.""" if in_maintenance(): return redirect(reverse("maintenance")) @@ -198,29 +229,36 @@ def add_or_edit(request, meeting_id=None): if RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False: return render(request, "meeting/add_or_edit.html", {"access_not_allowed": True}) + # User can manage a webinar and a live event for a webinar? + manage_webinar, manage_event = can_manage_webinar_and_event(request.user) + default_owner = meeting.owner.pk if meeting else request.user.pk + is_personal = meeting.is_personal if meeting else False form = MeetingForm( + request=request, instance=meeting, is_staff=request.user.is_staff, is_superuser=request.user.is_superuser, current_user=request.user, initial={"owner": default_owner}, + manage_webinar=manage_webinar, + is_personal=is_personal ) if request.method == "POST": form = MeetingForm( request.POST, + request=request, instance=meeting, is_staff=request.user.is_staff, is_superuser=request.user.is_superuser, current_user=request.user, current_lang=request.LANGUAGE_CODE, + manage_webinar=manage_webinar, + is_personal=is_personal ) if form.is_valid(): meeting = save_meeting_form(request, form) - display_message_with_icon( - request, messages.INFO, _("The changes have been saved.") - ) return redirect(reverse("meeting:my_meetings")) else: display_message_with_icon( @@ -241,11 +279,13 @@ def add_or_edit(request, meeting_id=None): "form": form, "start_date_formats": start_date_formats, "page_title": mark_safe(page_title), + "manage_webinar": manage_webinar, + "manage_event": manage_event }, ) -def save_meeting_form(request, form): +def save_meeting_form(request: WSGIRequest, form: MeetingForm) -> Meeting: """Save a meeting form.""" meeting = form.save(commit=False) meeting.site = get_current_site(request) @@ -258,15 +298,55 @@ def save_meeting_form(request, form): elif getattr(meeting, "owner", None) is None: meeting.owner = request.user + + # Meeting created or updated? + created = False if meeting.id else True + + # Specific case for a webinar + if meeting.is_webinar: + meeting.guest_policy = "ALWAYS_ACCEPT" + meeting.save() form.save_m2m() + + # Manage webinar + if ( + USE_MEETING_WEBINAR + and can_manage_webinar(request.user) + and meeting.is_webinar + ): + # Check if at least one live gateway is available during this meeting + # Search an available live gateway (None possible) + live_gateway = search_for_available_livegateway(request, meeting) + if live_gateway: + # Manage webinar for event and livestream + manage_webinar(meeting, created, live_gateway) + display_message_with_icon( + request, messages.INFO, _("The changes have been saved.") + ) + else: + # Disable webinar mode if no live gateway available + meeting.is_webinar = False + meeting.save() + display_message_with_icon( + request, messages.ERROR, _( + "It is not possible to hold a webinar during this period. " + "Webinar mode has been disabled for this meeting. " + "Please try to change the period or contact the administrator." + ) + ) + else: + display_message_with_icon( + request, messages.INFO, _("The changes have been saved.") + ) + return meeting @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def delete(request, meeting_id): +def delete(request: WSGIRequest, meeting_id: str) -> HttpResponse: """Delete a meeting.""" meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -291,6 +371,12 @@ def delete(request, meeting_id): if request.method == "POST": form = MeetingDeleteForm(request.POST) if form.is_valid(): + # Delete livestream and event created in the same time for a webinar + if meeting.is_webinar: + livestreams = Livestream.objects.filter(meeting=meeting) + for livestream in livestreams: + livestream.event.delete() + meeting.delete() display_message_with_icon( request, messages.INFO, _("The meeting has been deleted.") @@ -308,7 +394,7 @@ def delete(request, meeting_id): @csrf_protect @ensure_csrf_cookie -def join(request, meeting_id, direct_access=None): +def join(request: WSGIRequest, meeting_id: str, direct_access=None) -> HttpResponse: """Join a meeting.""" try: id = int(meeting_id[: meeting_id.find("-")]) @@ -336,7 +422,12 @@ def join(request, meeting_id, direct_access=None): return render_show_page(request, meeting, show_page, direct_access) -def render_show_page(request, meeting, show_page, direct_access): +def render_show_page( + request: WSGIRequest, + meeting: Meeting, + show_page: bool, + direct_access: bool +) -> HttpResponse: """Render show page.""" if show_page and direct_access and request.user.is_authenticated: # join as attendee @@ -362,7 +453,7 @@ def render_show_page(request, meeting, show_page, direct_access): """ -def join_as_moderator(request, meeting): +def join_as_moderator(request: WSGIRequest, meeting: Meeting) -> HttpResponse: """Join as a moderator.""" try: created = meeting.create(request) @@ -376,6 +467,10 @@ def join_as_moderator(request, meeting): join_url = meeting.get_join_url( fullname, "MODERATOR", request.user.get_username() ) + # Start the webinar if webinar mode and owner + if meeting.is_webinar and meeting.owner == request.user: + start_webinar(request, meeting.id) + return redirect(join_url) else: msg = "Unable to create meeting ! " @@ -398,7 +493,7 @@ def join_as_moderator(request, meeting): ) -def check_user(request): +def check_user(request: WSGIRequest) -> HttpResponse: """Check user.""" if request.user.is_authenticated: display_message_with_icon( @@ -409,7 +504,11 @@ def check_user(request): return redirect("%s?referrer=%s" % (settings.LOGIN_URL, request.get_full_path())) -def check_form(request, meeting, remove_password_in_form): +def check_form( + request: WSGIRequest, + meeting: Meeting, + remove_password_in_form: bool +) -> HttpResponse: """Check form.""" current_user = request.user if request.user.is_authenticated else None form = MeetingPasswordForm( @@ -462,7 +561,7 @@ def check_form(request, meeting, remove_password_in_form): ) -def is_in_meeting_groups(user, meeting): +def is_in_meeting_groups(user: User, meeting: Meeting) -> bool: """Return if user in the meeting.""" return user.owner.accessgroup_set.filter( code_name__in=[ @@ -471,7 +570,7 @@ def is_in_meeting_groups(user, meeting): ).exists() -def get_meeting_access(request, meeting): +def get_meeting_access(request: WSGIRequest, meeting: Meeting) -> bool: """Return True if access is granted to current user.""" is_restricted = meeting.is_restricted is_restricted_to_group = meeting.restrict_access_to_groups.all().exists() @@ -493,7 +592,7 @@ def get_meeting_access(request, meeting): @csrf_protect @ensure_csrf_cookie # @login_required(redirect_field_name="referrer") -def status(request, meeting_id): +def status(request: WSGIRequest, meeting_id: str) -> JsonResponse: """Status of a meeting, in JSON format.""" meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -514,25 +613,26 @@ def status(request, meeting_id): @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def end(request, meeting_id): +def end(request: WSGIRequest, meeting_id: str) -> HttpResponse: """End meeting, in JSON format..""" meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) ) if request.user != meeting.owner and not ( - request.user.is_superuser or request.user.has_perm("meeting.delete_meeting") + request.user.is_superuser or request.user.has_perm("meeting.end_meeting") ): display_message_with_icon( - request, messages.ERROR, _("You cannot delete this meeting.") + request, messages.ERROR, _("You cannot end this meeting.") ) raise PermissionDenied msg = "" try: meeting.end() + # Stop also webinar, if necessary + stop_webinar_mode(request, meeting) except ValueError as ve: args = ve.args[0] - msg = "" for key in args: msg += "%s: %s
    " % (key, args[key]) msg = mark_safe(msg) @@ -541,13 +641,21 @@ def end(request, meeting_id): else: if msg != "": display_message_with_icon(request, messages.ERROR, msg) + else: + display_message_with_icon( + request, + messages.INFO, + _( + "The meeting was successfully stopped." + ) + ) return redirect(reverse("meeting:my_meetings")) @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def get_meeting_info(request, meeting_id): +def get_meeting_info(request: WSGIRequest, meeting_id: str) -> JsonResponse: """Get meeting info, in JSON format.""" meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -580,19 +688,23 @@ def get_meeting_info(request, meeting_id): @login_required(redirect_field_name="referrer") -def get_internal_recordings(request, meeting_id, recording_id=None): +def get_internal_recordings( + request: WSGIRequest, + meeting_id: str, + recording_id=None +) -> list: """List the internal recordings, depends on parameters (core function). Args: - request (Request): HTTP request - meeting_id (String): meeting id (BBB format) - recording_id (String, optional): recording id (BBB format) + request (WSGIRequest): HTTP request + meeting_id (str): meeting id (BBB format) + recording_id (str, optional): recording id (BBB format) Raises: PermissionDenied: if user not allowed Returns: - recordings[]: Array of recordings corresponding to parameters + recordings[]: list of recordings corresponding to parameters """ meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -642,7 +754,7 @@ def get_internal_recordings(request, meeting_id, recording_id=None): @login_required(redirect_field_name="referrer") -def get_one_or_more_recordings(request, meeting, recording_id=None): +def get_one_or_more_recordings(request: WSGIRequest, meeting, recording_id=None) -> list: """Define recordings useful for get_internal_recordings function.""" if recording_id is None: meeting_recordings = meeting.get_recordings() @@ -652,18 +764,18 @@ def get_one_or_more_recordings(request, meeting, recording_id=None): @login_required(redirect_field_name="referrer") -def internal_recordings(request, meeting_id): +def internal_recordings(request: WSGIRequest, meeting_id: str) -> HttpResponse: """List the internal recordings (main function). Args: - request (Request): HTTP request - meeting_id (String): meeting id (BBB format) + request (WSGIRequest): HTTP request + meeting_id (str): meeting id (BBB format) Raises: PermissionDenied: if user not allowed Returns: - HTTPResponse: internal recordings list + HttpResponse: internal recordings list """ meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -685,19 +797,23 @@ def internal_recordings(request, meeting_id): @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def internal_recording(request, meeting_id, recording_id): +def internal_recording( + request: WSGIRequest, + meeting_id: str, + recording_id: str +) -> HttpResponse: """Get an internal recording, in JSON format (main function). Args: - request (Request): HTTP request - meeting_id (String): meeting id (BBB format) - recording_id (String): recording id (BBB format) + request (WSGIRequest): HTTP request + meeting_id (str): meeting id (BBB format) + recording_id (str): recording id (BBB format) Raises: PermissionDenied: if user not allowed Returns: - HTTPResponse: internal recording (JSON format) + HttpResponse: internal recording (JSON format) """ # Call the core function recordings = get_internal_recordings(request, meeting_id, recording_id) @@ -709,11 +825,11 @@ def internal_recording(request, meeting_id, recording_id): return HttpResponseBadRequest() -def secure_internal_recordings(request, meeting): +def secure_internal_recordings(request: WSGIRequest, meeting: Meeting): """Secure the internal recordings of a meeting. Args: - request (Request): HTTP request + request (WSGIRequest): HTTP request meeting (Meeting): Meeting instance Raises: @@ -733,15 +849,15 @@ def secure_internal_recordings(request, meeting): raise PermissionDenied -def get_can_delete_recordings(request, meeting): +def get_can_delete_recordings(request: WSGIRequest, meeting: Meeting) -> bool: """Check if user can delete, or not, a recording of this meeting. Args: - request (Request): HTTP request + request (WSGIRequest): HTTP request meeting (Meeting): Meeting instance Returns: - Boolean: True if current user can delete the recordings of this meeting. + bool: True if current user can delete the recordings of this meeting. """ can_delete = False @@ -757,19 +873,19 @@ def get_can_delete_recordings(request, meeting): @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def delete_internal_recording(request, meeting_id, recording_id): +def delete_internal_recording(request: WSGIRequest, meeting_id: str, recording_id: str): """Delete an internal recording. Args: - request (Request): HTTP request - meeting_id (String): meeting id (BBB format) - recording_id (String): recording id (BBB format) + request (WSGIRequest): HTTP request + meeting_id (str): meeting id (BBB format) + recording_id (str): recording id (BBB format) Raises: PermissionDenied: if user not allowed Returns: - HTTP Response: Redirect to the recordings list + HttpResponse: Redirect to the recordings list """ meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -800,7 +916,7 @@ def delete_internal_recording(request, meeting_id, recording_id): return redirect(reverse("meeting:internal_recordings", args=(meeting.meeting_id,))) -def get_meeting_info_json(info): +def get_meeting_info_json(info: list) -> dict: """Get meeting info in JSON format.""" response = {} for key in info: @@ -816,7 +932,7 @@ def get_meeting_info_json(info): return response -def end_callback(request, meeting_id): +def end_callback(request: WSGIRequest, meeting_id: str) -> HttpResponse: """End the BBB callback.""" meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -829,7 +945,7 @@ def end_callback(request, meeting_id): @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def invite(request, meeting_id): +def invite(request: WSGIRequest, meeting_id: str) -> HttpResponse: """Invite users to a BBB meeting.""" meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -867,7 +983,7 @@ def invite(request, meeting_id): ) -def get_dest_emails(meeting, form): +def get_dest_emails(meeting: Meeting, form: MeetingInviteForm) -> list: """Recipient emails.""" emails = form.cleaned_data["emails"] if form.cleaned_data["owner_copy"] is True: @@ -877,7 +993,7 @@ def get_dest_emails(meeting, form): return emails -def send_invite(request, meeting, emails): +def send_invite(request: WSGIRequest, meeting: Meeting, emails: list): """Send invitations to users.""" subject = _("%(owner)s invites you to the meeting %(meeting_title)s") % { "owner": meeting.owner.get_full_name(), @@ -900,7 +1016,7 @@ def send_invite(request, meeting, emails): os.remove(filename_event) -def get_html_content(request, meeting): +def get_html_content(request: WSGIRequest, meeting: Meeting) -> str: """Get HTML format content.""" join_link = request.build_absolute_uri( reverse("meeting:join", args=(meeting.meeting_id,)) @@ -990,7 +1106,7 @@ def get_html_content(request, meeting): return html_content -def create_ics(request, meeting): +def create_ics(request: WSGIRequest, meeting: Meeting) -> str: """Create ICS format.""" join_link = request.build_absolute_uri( reverse("meeting:join", args=(meeting.meeting_id,)) @@ -1057,7 +1173,7 @@ def create_ics(request, meeting): return "\n".join(filter(None, event_lines)) -def get_rrule(meeting): +def get_rrule(meeting: Meeting) -> str: """Get recurrence rule. i.e: @@ -1091,7 +1207,7 @@ def get_rrule(meeting): return rrule -def get_video_url(request, meeting_id, recording_id): +def get_video_url(request: WSGIRequest, meeting_id: str, recording_id: str) -> str: """Get recording video URL.""" meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -1109,19 +1225,23 @@ def get_video_url(request, meeting_id, recording_id): @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def upload_internal_recording_to_pod(request, recording_id, meeting_id): +def upload_internal_recording_to_pod( + request: WSGIRequest, + recording_id: str, + meeting_id: str +) -> HttpResponse: """Upload internal recording to Pod. Args: - request (Request): HTTP request - recording_id (String): recording id (BBB format) - meeting_id (String): meeting id (BBB format) + request (WSGIRequest): HTTP request + recording_id (str): recording id (BBB format) + meeting_id (str): meeting id (BBB format) Raises: PermissionDenied: if user not allowed Returns: - HTTP Response: Redirect to the recordings list + HttpResponse: Redirect to the recordings list """ meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) @@ -1170,16 +1290,20 @@ def upload_internal_recording_to_pod(request, recording_id, meeting_id): # ############################## Upload recordings to Pod def save_internal_recording( - request, recording_id, recording_name, meeting_id, source_url=None + request: WSGIRequest, + recording_id: str, + recording_name: str, + meeting_id: str, + source_url=None ): """Save an internal recording in database. Args: - request (Request): HTTP request - recording_id (String): recording id (BBB format) - recording_name (String): recording name - meeting_id (String): meeting id (BBB format) - source_url (String, optional): Video file URL. Defaults to None. + request (WSGIRequest): HTTP request + recording_id (str): recording id (BBB format) + recording_name (str): recording name + meeting_id (str): meeting id (BBB format) + source_url (str, optional): Video file URL. Defaults to None. Raises: ValueError: if impossible creation @@ -1220,19 +1344,23 @@ def save_internal_recording( raise ValueError(msg) -def upload_recording_to_pod(request, record_id, meeting_id=None): +def upload_recording_to_pod( + request: WSGIRequest, + record_id: int, + meeting_id=None +) -> bool: """Upload recording to Pod (main function). Args: - request (Request): HTTP request - record_id (Integer): id record in the database - meeting_id (String, optional): meeting id (BBB format) for internal recording. + request (WSGIRequest): HTTP request + record_id (int): id record in the database + meeting_id (str, optional): meeting id (BBB format) for internal recording. Raises: ValueError: exception raised if no URL found or other problem Returns: - Boolean: True if upload achieved + bool: True if upload achieved """ try: # Check that request is correct for upload @@ -1256,19 +1384,23 @@ def upload_recording_to_pod(request, record_id, meeting_id=None): raise ValueError(msg) -def upload_bbb_recording_to_pod(request, record_id, meeting_id): +def upload_bbb_recording_to_pod( + request: WSGIRequest, + record_id: int, + meeting_id: str +) -> bool: """Upload a BBB or video file recording to Pod. Args: - request (Request): HTTP request - record_id (Integer): id record in the database - meeting_id (String, optional): meeting id (BBB format) for internal recording. + request (WSGIRequest): HTTP request + record_id (Iint): id record in the database + meeting_id (str, optional): meeting id (BBB format) for internal recording. Raises: ValueError: exception raised if no video found at this URL Returns: - Boolean: True if upload achieved + bool: True if upload achieved """ try: # Session useful to achieve requests (and keep cookies between) @@ -1345,13 +1477,13 @@ def upload_bbb_recording_to_pod(request, record_id, meeting_id): @csrf_exempt -def recording_ready(request): +def recording_ready(request: WSGIRequest) -> HttpResponse: """Make a callback when a recording is ready for viewing. Useful to send an email to prevent the user. See https://docs.bigbluebutton.org/development/api/#recording-ready-callback-url Args: - request (Request): HTTP request + request (WSGIRequest): HTTP request Returns: HttpResponse: empty response @@ -1385,3 +1517,142 @@ def recording_ready(request): % (meeting_id, recording_id, mark_safe(str(exc)), traceback.format_exc()) ) return HttpResponse() + + +def can_manage_webinar(user: User) -> bool: + """Find out if the user can manage a webinar. + + Specific case: not allowed for a personal room. + """ + return user.is_authenticated and ( + user.is_superuser + or user.owner.accessgroup_set.filter( + code_name__in=MEETING_WEBINAR_AFFILIATION + ).exists() + or user.groups.filter(name=MEETING_WEBINAR_GROUP_ADMIN).exists() + ) + + +def can_manage_webinar_and_event(user: User): + """Manage a webinar and event are possible for the user?""" + # User can manage a webinar? + if USE_MEETING_WEBINAR and can_manage_webinar(user): + manage_webinar = True + else: + manage_webinar = False + + # User can manage the live event, for a webinar? + if manage_webinar and can_manage_event(user): + manage_event = True + else: + manage_event = False + return manage_webinar, manage_event + + +def can_end_meeting(request: WSGIRequest, meeting: Meeting) -> bool: + """Shows if the user can stop a meeting.""" + if request.user != meeting.owner and not ( + request.user.is_superuser or request.user.has_perm("meeting.end_meeting") + ): + return False + return True + + +def stop_webinar_mode(request: WSGIRequest, meeting: Meeting): + """Stop webinar mode if meeting is a webinar.""" + if meeting.is_webinar: + # Stop webinar without delay + stop_webinar(request, meeting.id) + + +def live_publish_chat_if_authenticated(user: User) -> bool: + """Only an authenticated user can send chat question to a webinar.""" + if user.__str__() == "AnonymousUser": + return False + return True + + +@csrf_protect +@ensure_csrf_cookie +@login_required(redirect_field_name="referrer") +def end_live(request: WSGIRequest, meeting_id: str) -> HttpResponse: + """End live for a webinar.""" + meeting = get_object_or_404( + Meeting, meeting_id=meeting_id, site=get_current_site(request) + ) + + if request.user != meeting.owner and not ( + request.user.is_superuser or request.user.has_perm("meeting.end_meeting") + ): + display_message_with_icon( + request, messages.ERROR, _("You can't end this webinar live.") + ) + raise PermissionDenied + # Stop also webinar, if necessary + stop_webinar_mode(request, meeting) + return redirect(reverse("meeting:my_meetings")) + + +@csrf_protect +@ensure_csrf_cookie +@login_required(redirect_field_name="referrer") +def restart_live(request: WSGIRequest, meeting_id: str) -> HttpResponse: + """Restart live for a webinar.""" + meeting = get_object_or_404( + Meeting, meeting_id=meeting_id, site=get_current_site(request) + ) + + if request.user != meeting.owner and not ( + request.user.is_superuser or request.user.has_perm("meeting.end_meeting") + ): + display_message_with_icon( + request, messages.ERROR, _("You can't restart this webinar live.") + ) + raise PermissionDenied + msg = "" + try: + if meeting.is_webinar: + # Stop webinar livestream without delay + stop_webinar(request, meeting.id) + time.sleep(5) + # And start webinar + start_webinar(request, meeting.id) + except ValueError as ve: + args = ve.args[0] + for key in args: + msg += "%s: %s
    " % (key, args[key]) + msg = mark_safe(msg) + if msg != "": + display_message_with_icon(request, messages.ERROR, msg) + return redirect(reverse("meeting:my_meetings")) + + +@csrf_protect +@user_passes_test(live_publish_chat_if_authenticated, redirect_field_name="referrer") +def live_publish_chat(request: WSGIRequest, id=None) -> JsonResponse: + """Allow an authenticated user to send chat question to a webinar.""" + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + # Initial data return + data = {"message_return": "message_sent", "is_sent": True} + # Authenticated user + who_sent = "(%s %s) " % (request.user.first_name, request.user.last_name) + + body_unicode = request.body.decode("utf-8") + body_data = json.loads(body_unicode) + message = body_data["message"] + + # Get the event to find the related meeting + event = Event.objects.get(id=id) + if USE_MEETING and USE_MEETING_WEBINAR: + livestream = Livestream.objects.filter(event=event).first() + if livestream and livestream.meeting.is_webinar: + # Send a chat request to SIPMediaGW + try: + chat_rtmp_gateway(livestream.meeting.id, who_sent + message) + except ValueError: + data = {"message_return": "error", "is_sent": False} + else: + data = {"message_return": "error", "is_sent": False} + return JsonResponse(data) diff --git a/pod/meeting/webinar.py b/pod/meeting/webinar.py new file mode 100644 index 0000000000..e228c6924f --- /dev/null +++ b/pod/meeting/webinar.py @@ -0,0 +1,417 @@ +"""Management of webinars for the Meeting module.""" + +import json +import logging +import requests +import threading +import time + +from django.conf import settings +from django.contrib import messages +from django.core.handlers.wsgi import WSGIRequest +from django.utils.html import mark_safe +from django.utils.translation import ugettext_lazy as _ +from pod.main.utils import display_message_with_icon +from pod.meeting.models import Meeting, Livestream +from pod.meeting.utils import slash_join + +# URL of the SIPMediaGW server that manages webinars +MEETING_WEBINAR_SIPMEDIAGW_URL = getattr( + settings, + "MEETING_WEBINAR_SIPMEDIAGW_URL", + "" +) +# Bearer token for the SIPMediaGW server that manages webinars +MEETING_WEBINAR_SIPMEDIAGW_TOKEN = getattr( + settings, + "MEETING_WEBINAR_SIPMEDIAGW_TOKEN", + "" +) + +log = logging.getLogger("webinar") + + +def start_webinar(request: WSGIRequest, meet_id: int): + """Start a webinar and send a thread to stop it automatically at the end.""" + try: + # Get the current meeting + meeting = Meeting.objects.get(id=meet_id) + + # No thread for start the webinar + start_webinar_livestream(request.get_host(), meet_id) + + # Thread for stop the webinar + tStop = threading.Thread(target=stop_webinar_livestream, args=[meet_id, False]) + tStop.setDaemon(True) + tStop.start() + display_message_with_icon( + request, messages.INFO, _( + "Webinar mode has been successfully started for “%s” meeting." + ) % (meeting.name) + ) + # Manage enable_chat is False by default + if meeting.enable_chat is False: + # Send a toggle request to SIPMediaGW + toggle_rtmp_gateway(meet_id) + except Exception as exc: + log.error( + "Error to start webinar mode for “%s” meeting: %s" % ( + meet_id, + str(exc) + ) + ) + display_message_with_icon( + request, messages.ERROR, _( + "Error to start webinar mode for “%s” meeting: %s" + ) % ( + meeting.name, + str(exc) + ) + ) + + +def stop_webinar(request: WSGIRequest, meet_id: int): + """Stop the webinar.""" + try: + # Get the current meeting + meeting = Meeting.objects.get(id=meet_id) + + # No thread for stop the webinar in such a case + stop_webinar_livestream(meet_id, True) + + display_message_with_icon( + request, messages.INFO, _( + "Webinar mode has been successfully stopped for “%s” meeting." + ) % (meeting.name) + ) + except Exception as exc: + log.error( + "Error to stop webinar mode for “%s” meeting: %s" % + ( + meet_id, + str(exc) + ) + ) + display_message_with_icon( + request, messages.ERROR, _( + "Error to stop webinar mode for “%s” meeting: %s" + ) % ( + meeting.name, + str(exc) + ) + ) + + +def start_webinar_livestream(pod_host: str, meet_id: int): + """Run the steps to start the webinar livestream.""" + try: + if pod_host.find("localhost") != -1: + raise ValueError( + _( + "it is not possible to use a development server " + "(localhost) for this functionality." + ) + ) + # Get the current meeting + meeting = Meeting.objects.get(id=meet_id) + + # Manage meeting's livestream + livestream = manage_meeting_livestream(meeting) + + # Start RTMP Gateway for SIPMediaGW + start_rtmp_gateway(pod_host, meet_id, livestream.id) + except Exception as exc: + log.error( + "Error to start webinar mode for “%s” meeting: %s" % ( + meet_id, + str(exc) + ) + ) + raise ValueError(str(exc)) + + +def stop_webinar_livestream(meet_id: int, force: bool): + """Stop the webinar when meeting is stopped or when user forces to stop it.""" + try: + log.info( + "stop_webinar_livestream %s: %s" % ( + meet_id, + "stop" + ) + ) + # Get the meeting + meeting = Meeting.objects.get(id=meet_id) + # Search for the livestream used for this webinar + livestream_in_progress = Livestream.objects.filter( + meeting=meeting, + status=1 + ).first() + # When not forced, wait to meeting's end to stop RTMP gateway + # After 5h (max duration for a meeting), stop the RTMP gateway + if not force: + # Wait for the meeting to end + wait_meeting_is_stopped(meeting) + + if livestream_in_progress: + # Stop RTMP Gateway for SIPMediaGW + stop_rtmp_gateway(meet_id) + + # Change livestream status + livestream_in_progress.status = 2 + livestream_in_progress.save() + else: + log.error( + "No livestream object found for webinar id %s" % meet_id + ) + + except Exception as exc: + log.error( + "Error to stop webinar mode for “%s” meeting: %s" % ( + meet_id, + str(exc) + ) + ) + if force: + raise ValueError(str(exc)) + + +def wait_meeting_is_stopped(meeting: Meeting): + """Check regularly if meeting is stopped. + + If meeting is running, wait to make another check (5h max). + If meeting was stopped, continue without delay. + """ + # Meeting is stopped? + is_stopped = False + # Check timeout if BBB meeting is still running (in seconds) + delay = 60 + + i = 1 + time.sleep(delay) + while i < int(18000 / delay): + # Check regularly + if meeting.get_is_meeting_running() is True: + is_stopped = False + log.info( + "check status for meeting %s “%s”: %s" % ( + meeting.id, + meeting.name, + "Meeting is running" + ) + ) + time.sleep(delay) + else: + log.info( + "check status for meeting %s “%s”: %s" % ( + meeting.id, + meeting.name, + "Meeting is not running" + ) + ) + # Exit if meeting was stopped during 2 checks + if is_stopped: + break + else: + time.sleep(delay) + is_stopped = True + i += 1 + + +def manage_meeting_livestream(meeting: Meeting): + """Manage the meeting's livestream.""" + # Search existant livestream for this meeting + livestream = Livestream.objects.filter( + meeting=meeting, + ).first() + if livestream: + # Live in progress + livestream.status = 1 + livestream.save() + else: + log.error("No livestream object found for webinar id %s" % meeting.id) + return livestream + + +def start_rtmp_gateway(pod_host: str, meet_id: int, livestream_id: int): + """Run the start command for SIPMediaGW RTMP gateway.""" + # Get the current meeting + meeting = Meeting.objects.get(id=meet_id) + livestream = Livestream.objects.get(id=livestream_id) + # Base URL; example format: pod.univ.fr/meeting/##id##/##hashkey## + meeting_base_url = slash_join( + pod_host, + "meeting", + meeting.meeting_id, + meeting.get_hashkey() + ) + # Room used (last 10 caracters) + room = meeting.get_hashkey()[-10:] + # Domain (without last 10 caracters) + domain = meeting_base_url[:-10] + # RTMP stream URL + rtmp_stream_url = livestream.live_gateway.rtmp_stream_url + # Start URL on SIPMediaGW server + sipmediagw_url = slash_join( + MEETING_WEBINAR_SIPMEDIAGW_URL, + "start" + ) + + # SIPMediaGW start request + headers = { + 'Authorization': 'Bearer %s' % MEETING_WEBINAR_SIPMEDIAGW_TOKEN, + } + params = { + 'room': room, + 'domain': domain, + 'rtmpDst': rtmp_stream_url, + } + response = requests.get( + sipmediagw_url, + params=params, + headers=headers, + verify=False + ) + # Output in JSON (ex: {"res": "ok", "app": "streaming", "uri": ""}) + json_response = json.loads(response.text) + + log.info( + "start_rtmp_gateway for meeting %s “%s”: %s" % ( + meeting.id, + meeting.name, + response.text + ) + ) + + if json_response["res"] != "ok": + message = json_response["type"] + raise ValueError(mark_safe(message)) + + +def stop_rtmp_gateway(meet_id: int): + """Run the stop command for SIPMediaGW RTMP gateway.""" + # Get the current meeting + meeting = Meeting.objects.get(id=meet_id) + # Room used (last 10 caracters) + room = meeting.get_hashkey()[-10:] + # Stop URL on SIPMediaGW server + sipmediagw_url = slash_join( + MEETING_WEBINAR_SIPMEDIAGW_URL, + "stop" + ) + + # SIPMediaGW stop request + headers = { + 'Authorization': 'Bearer %s' % MEETING_WEBINAR_SIPMEDIAGW_TOKEN, + } + params = { + 'room': room, + } + response = requests.get( + sipmediagw_url, + params=params, + headers=headers, + verify=False + ) + # Output in JSON (ex: {"res": "Container gw0 Stopping =>... Container gw0 Removed"}) + json_response = json.loads(response.text) + + log.info( + "stop_rtmp_gateway for meeting %s “%s”: %s" % ( + meeting.id, + meeting.name, + response.text + ) + ) + + if json_response["res"].find("Warning") != -1: + message = json_response["res"] + raise ValueError(mark_safe(message)) + + +def toggle_rtmp_gateway(meet_id: int): + """Run the toggle (to show chat or not) command for SIPMediaGW RTMP gateway.""" + # Get the current meeting + meeting = Meeting.objects.get(id=meet_id) + # Room used (last 10 caracters) + room = meeting.get_hashkey()[-10:] + # Toogle URL on SIPMediaGW server + sipmediagw_url = slash_join( + MEETING_WEBINAR_SIPMEDIAGW_URL, + "chat" + ) + + # SIPMediaGW toogle request + headers = { + 'Authorization': 'Bearer %s' % MEETING_WEBINAR_SIPMEDIAGW_TOKEN, + } + params = { + 'room': room, + 'toggle': True + } + response = requests.get( + sipmediagw_url, + params=params, + headers=headers, + verify=False + ) + # Specific error message when not started + message = response.text + # Output in JSON (ex: {"res": "ok"}) + json_response = json.loads(response.text) + if json_response["res"] != "ok": + message = "Toogle was sent before SIPMediaGW start (%s)" % response.text + + log.info( + "toggle_rtmp_gateway for meeting %s “%s”: %s" % ( + meeting.id, + meeting.name, + message + ) + ) + + +def chat_rtmp_gateway(meet_id: int, msg: str): + """Send message command to SIPMediaGW RTMP gateway.""" + # Get the current meeting + meeting = Meeting.objects.get(id=meet_id) + # Room used (last 10 caracters) + room = meeting.get_hashkey()[-10:] + # Toogle URL on SIPMediaGW server + sipmediagw_url = slash_join( + MEETING_WEBINAR_SIPMEDIAGW_URL, + "chat" + ) + + # SIPMediaGW toogle request + headers = { + 'Content-Type': 'application/json', + } + # Manage quotes in msg + msg = msg.replace("'", "’") + msg = msg.replace('"', "’") + json_data = { + 'room': room, + 'msg': msg + } + response = requests.post( + sipmediagw_url, + headers=headers, + json=json_data, + verify=False + ) + + message = response.text + # Output in JSON (ex: {"res": "ok"}) + json_response = json.loads(response.text) + + log.info( + "chat_rtmp_gateway for meeting %s “%s”: %s" % ( + meeting.id, + meeting.name, + message + ) + ) + + if json_response["res"].find("ok") == -1: + message = json_response["res"] + raise ValueError(mark_safe(message)) diff --git a/pod/meeting/webinar_utils.py b/pod/meeting/webinar_utils.py new file mode 100644 index 0000000000..f6b5817e4e --- /dev/null +++ b/pod/meeting/webinar_utils.py @@ -0,0 +1,210 @@ +"""Utils to manage webinars for Meeting module.""" + +from django.conf import settings +from django.contrib.sites.shortcuts import get_current_site +from django.core.handlers.wsgi import WSGIRequest +from django.core.mail import mail_admins +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ +from .models import Meeting, LiveGateway, Livestream +from pod.live.models import Event +from pod.main.views import TEMPLATE_VISIBLE_SETTINGS +from pod.video.models import Type + +__TITLE_SITE__ = ( + TEMPLATE_VISIBLE_SETTINGS["TITLE_SITE"] + if (TEMPLATE_VISIBLE_SETTINGS.get("TITLE_SITE")) + else "Pod" +) + +DEFAULT_EVENT_TYPE_ID = getattr(settings, "DEFAULT_EVENT_TYPE_ID", 1) + + +def search_for_available_livegateway(request: WSGIRequest, meeting: Meeting) -> LiveGateway: # noqa: C901 + """Search and returns a live gateway available during the period of the webinar. + + If more webinars are created than live gateways, an email is sent to warn administrators. + In such a case, this function returns a None value. + """ + site = get_current_site(request) + # List of live gateways used + live_gateways_id_used = [] + + # Tip to allow same date format + meeting = Meeting.objects.get(id=meeting.id) + # All recent webinars - older webinars are not included (5h is max duration) + # Not including the current webinar + webinars_list = list( + Meeting.objects.filter( + is_webinar=True, + start_at__gte=timezone.now() - timezone.timedelta(hours=5), + site=site + ).exclude(id=meeting.id)) + nb_webinars = 0 + names_webinars = "" + meeting_end_date = meeting.start_at + meeting.expected_duration + # Search for live gateways at the same moment of this webinar + for webinar in webinars_list: + webinar_overlapping = False + webinar_end_date = webinar.start_at + webinar.expected_duration + # Search on the overlapping period + if ( + meeting.start_at >= webinar.start_at and meeting.start_at < webinar_end_date + ): + webinar_overlapping = True + elif ( + meeting.start_at <= webinar.start_at and meeting_end_date > webinar.start_at + ): + webinar_overlapping = True + elif ( + meeting.start_at >= webinar.start_at and meeting_end_date < webinar_end_date + ): + webinar_overlapping = True + elif ( + meeting.start_at <= webinar.start_at and meeting_end_date > webinar_end_date + ): + webinar_overlapping = True + + if webinar_overlapping: + names_webinars += "%s, " % webinar.name + nb_webinars += 1 + # Last livestream for the webinar + livestream = Livestream.objects.filter( + meeting=webinar + ).order_by('-id').first() + if livestream: + # Live gateway already used, add it to the list + live_gateways_id_used.append(livestream.live_gateway.id) + + # Available live gateway at the same moment of this webinar + live_gateway_available = LiveGateway.objects.filter( + site=site + ).exclude(id__in=live_gateways_id_used).first() + + # Number total of live gateways + nb_live_gateways = LiveGateway.objects.filter(site=site).count() + # Remember that nb_webinars does not include the current webinar + if nb_webinars + 1 > nb_live_gateways: + # Send notification to administrators + send_email_webinars(meeting, nb_webinars + 1, nb_live_gateways, names_webinars) + + # None possible + return live_gateway_available + + +def send_email_webinars( + meeting: Meeting, + nb_webinars: int, + nb_live_gateways: int, + names_webinars: str +): + """Send email notification to administrators when too many webinars.""" + subject = "[" + __TITLE_SITE__ + "] %s" % _("Too many webinars") + message = _( + "There are too many webinars (%s) for the number of " + "live gateways allocated (%s). " + "The next meeting has been created but not like a webinar:%s %s [%s-%s].\n" + "Please fix the problem either by increasing the number of live gateways " + "or by modifying/deleting one of the affected webinars " + "(with the users' agreement).\n" + "Other webinars: %s" + ) % ( + nb_webinars, + nb_live_gateways, + meeting.id, + meeting.name, + meeting.start_at, + meeting.start_at + meeting.expected_duration, + names_webinars + ) + html_message = _( + "

    There are too many webinars (%s) for the number of " + "live gateways allocated (%s). " + "The next webinar has been created but not like a webinar:" + "

    • %s %s [%s-%s].

    " + "Please fix the problem either by increasing the number of live gateways " + "or by modifying/deleting one of the affected webinars " + "(with the users' agreement).
    " + "Other webinars: %s" + ) % ( + nb_webinars, + nb_live_gateways, + meeting.id, + meeting.name, + meeting.start_at, + meeting.start_at + meeting.expected_duration, + names_webinars + ) + mail_admins(subject, message, fail_silently=False, html_message=html_message) + + +def manage_webinar(meeting: Meeting, created: bool, live_gateway: LiveGateway): # noqa: C901 + """Manage the livestream and the event when a webinar is created or updated.""" + # When created a webinar + if meeting.is_webinar and created: + # No reccurence for a webinar + meeting.reccurence = None + meeting.save() + # Create livestream and event + create_livestream_event(meeting, live_gateway) + + # Search if a livestream exists for this meeting + livestream = Livestream.objects.filter(meeting=meeting).first() + + # When updated a webinar + if meeting.is_webinar and not created and livestream: + # If update on meeting (for event-related fields) was achieved + if livestream.event.title != meeting.name: + livestream.event.title = meeting.name + if livestream.event.start_date != meeting.start_at: + livestream.event.start_date = meeting.start_at + if livestream.event.end_date != meeting.start_at + meeting.expected_duration: + livestream.event.end_date = meeting.start_at + meeting.expected_duration + if livestream.event.is_restricted != meeting.is_restricted: + livestream.event.is_restricted = meeting.is_restricted + livestream.event.additional_owners.set( + meeting.additional_owners.all() + ) + livestream.event.restrict_access_to_groups.set( + meeting.restrict_access_to_groups.all() + ) + + # Update the livestream event + livestream.event.save() + + # When check is_webinar for an existent meeting + if meeting.is_webinar and not created and not livestream: + # Create livestream and event + create_livestream_event(meeting, live_gateway) + + # When uncheck is_webinar for an existent meeting + if not meeting.is_webinar and livestream: + # Delete livestream and event if it's not a webinar (unchecked) + livestream.event.delete() + + +def create_livestream_event(meeting: Meeting, live_gateway: LiveGateway): + """Create a livestream and an event for a new webinar.""" + # Create live event + event = Event.objects.create( + title=meeting.name, + owner=meeting.owner, + broadcaster=live_gateway.broadcaster, + type=Type.objects.get(id=DEFAULT_EVENT_TYPE_ID), + start_date=meeting.start_at, + end_date=meeting.start_at + meeting.expected_duration, + is_draft=False, + is_restricted=meeting.is_restricted, + ) + event.restrict_access_to_groups.set( + meeting.restrict_access_to_groups.all() + ) + + # Create the livestream + Livestream.objects.create( + meeting=meeting, + # Status : live not started + status=0, + event=event, + live_gateway=live_gateway + )