From d6f69215c7ac3d131eb7807252023cb676c2fc94 Mon Sep 17 00:00:00 2001 From: Olivier Bado Date: Fri, 29 Mar 2024 12:06:43 +0100 Subject: [PATCH 01/37] Move "aside" just after "aside-collapse" button for better keayboard navigation --- pod/main/static/css/pod.css | 21 +++++++++++++++---- pod/main/templates/base.html | 40 +++++++++++++++++++----------------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/pod/main/static/css/pod.css b/pod/main/static/css/pod.css index df0e184e2d..92b6651c20 100755 --- a/pod/main/static/css/pod.css +++ b/pod/main/static/css/pod.css @@ -1103,9 +1103,9 @@ body { color: var(--pod-btn-text); } -.pod-aside-collapse[aria-expanded="true"] { +/*.pod-aside-collapse[aria-expanded="true"] { transform: rotate(-90deg); -} +}*/ /*** footer **/ .pod-footer { @@ -1332,16 +1332,16 @@ body { /* small screens */ .pod-grid-content { - display: grid; - grid-template-columns: minmax(0%, 100%) repeat(auto-fit, 100%); position: relative; } /* medium screens */ @media (width >= 992px) { .pod-grid-content { + display: grid; gap: 2rem; grid-template-columns: minmax(0%, 100%) repeat(auto-fit, 33%); + grid-template-areas: "main sidebar"; } } @@ -1367,6 +1367,7 @@ body { } .pod-aside { + grid-area: sidebar; display: grid; gap: 1rem; grid-template-columns: 1fr; @@ -1375,19 +1376,25 @@ body { grid-auto-rows: min-content; background: var(--pod-background-neutre2-bloc); padding: 1rem; + + /* width: 0; overflow: hidden; margin-left: 100%; transition: all 0.15s ease; + */ z-index: 5; padding-top: 5rem; box-shadow: 0.05rem 0.05rem 0.2rem 0.05rem rgb(0 0 0 / 20%); + } +/* .pod-aside.show { margin-left: 0; width: auto; } +*/ @media (width >= 992px) { .pod-show-lg { @@ -1397,12 +1404,16 @@ body { } @media (max-width: 992px) { + /* .pod-aside { /* THIS IS NORMAL, it's to fit with pod-aside-collapse */ + + /* width: 101% !important; min-height: 100%; position: absolute; } + */ .pod-aside-collapse { position: fixed; @@ -1420,9 +1431,11 @@ body { } } +/* .pod-aside.collapsing { height: auto; } +*/ .pod-aside .card { background: none; diff --git a/pod/main/templates/base.html b/pod/main/templates/base.html index 56f4912265..07fc9580e0 100644 --- a/pod/main/templates/base.html +++ b/pod/main/templates/base.html @@ -74,21 +74,30 @@ {% endif %}
+ {% if not request.GET.is_iframe %} +
+ {% block collapse_page_aside %} + + {% endblock collapse_page_aside %} +
+ {% endif %}
+ {% if not request.GET.is_iframe %} + + {% endif %}
{% if not request.GET.is_iframe %} -
- {% block collapse_page_aside %} - - {% endblock collapse_page_aside %} -
{% if MAINTENANCE_SHEDULED %} {% endif %} @@ -127,13 +136,6 @@

{{page_title|capfirst}}

{% endif %}
- {% if not request.GET.is_iframe %} - - {% endif %}
{% if USE_NOTIFICATIONS %} From 004120d7ab693e9860d6f45f1eb9d0f54a7439ef Mon Sep 17 00:00:00 2001 From: Olivier Bado Date: Fri, 29 Mar 2024 14:13:56 +0100 Subject: [PATCH 02/37] Add CSS comments + remove unused CSS --- pod/main/static/css/pod.css | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pod/main/static/css/pod.css b/pod/main/static/css/pod.css index 92b6651c20..06a6578eb5 100755 --- a/pod/main/static/css/pod.css +++ b/pod/main/static/css/pod.css @@ -1103,9 +1103,12 @@ body { color: var(--pod-btn-text); } -/*.pod-aside-collapse[aria-expanded="true"] { +/* Commented in 3.6.0 +[Todo] remove in 3.7.0 +.pod-aside-collapse[aria-expanded="true"] { transform: rotate(-90deg); -}*/ +} +*/ /*** footer **/ .pod-footer { @@ -1378,6 +1381,8 @@ body { padding: 1rem; /* + Commented in 3.6.0 + [Todo] remove in 3.7.0 width: 0; overflow: hidden; margin-left: 100%; @@ -1390,25 +1395,23 @@ body { } /* +Commented in 3.6.0 +[Todo] remove in 3.7.0 + */ .pod-aside.show { margin-left: 0; width: auto; } */ -@media (width >= 992px) { - .pod-show-lg { - margin-left: 0; - width: auto; - } -} - @media (max-width: 992px) { /* .pod-aside { /* THIS IS NORMAL, it's to fit with pod-aside-collapse */ /* + Commented in 3.6.0 + [Todo] remove in 3.7.0 width: 101% !important; min-height: 100%; position: absolute; @@ -1432,6 +1435,8 @@ body { } /* +Commented in 3.6.0 +[Todo] remove in 3.7.0 .pod-aside.collapsing { height: auto; } From d5a9cece4bfced3a39b9ee2cbd563153f2e3515f Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 29 Mar 2024 13:45:57 +0000 Subject: [PATCH 03/37] Fixup. Format code with Prettier --- pod/main/static/css/pod.css | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pod/main/static/css/pod.css b/pod/main/static/css/pod.css index 06a6578eb5..8cebdec962 100755 --- a/pod/main/static/css/pod.css +++ b/pod/main/static/css/pod.css @@ -1391,7 +1391,6 @@ body { z-index: 5; padding-top: 5rem; box-shadow: 0.05rem 0.05rem 0.2rem 0.05rem rgb(0 0 0 / 20%); - } /* @@ -1402,14 +1401,12 @@ Commented in 3.6.0 margin-left: 0; width: auto; } -*/ - -@media (max-width: 992px) { +*/ @media (max-width: 992px) { /* .pod-aside { /* THIS IS NORMAL, it's to fit with pod-aside-collapse */ - /* + /* Commented in 3.6.0 [Todo] remove in 3.7.0 width: 101% !important; From dbfd2ca4560166737ef262779c1fe7cda1ed9df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Coze?= <96086580+SebastienCozeDev@users.noreply.github.com> Date: Fri, 29 Mar 2024 17:20:30 +0100 Subject: [PATCH 04/37] [DONE] Fix the auto fill video password field (#1086) * Fix the password field * Add pydoc * Update the documentation --- pod/video/forms.py | 54 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/pod/video/forms.py b/pod/video/forms.py index ef21ef61cb..06502fcda4 100644 --- a/pod/video/forms.py +++ b/pod/video/forms.py @@ -1,3 +1,5 @@ +"""Forms for Esup-Pod video app.""" + from django import forms from django.contrib.admin import widgets from django.contrib.auth.models import User @@ -428,6 +430,7 @@ class CustomClearableFileInput(ClearableFileInput): class OwnerWidget(s2forms.ModelSelect2Widget): + """Widget for selecting a single owner.""" search_fields = [ "username__icontains", "email__icontains", @@ -435,6 +438,7 @@ class OwnerWidget(s2forms.ModelSelect2Widget): class AddOwnerWidget(s2forms.ModelSelect2MultipleWidget): + """Widget for selecting multiple owners.""" search_fields = [ "username__icontains", "email__icontains", @@ -442,6 +446,7 @@ class AddOwnerWidget(s2forms.ModelSelect2MultipleWidget): class AddAccessGroupWidget(s2forms.ModelSelect2MultipleWidget): + """Widget for selecting multiple access groups.""" search_fields = [ "display_name__icontains", "code_name__icontains", @@ -449,12 +454,14 @@ class AddAccessGroupWidget(s2forms.ModelSelect2MultipleWidget): class ChannelWidget(s2forms.ModelSelect2MultipleWidget): + """Widget for selecting multiple channels.""" search_fields = [ "title__icontains", ] class DisciplineWidget(s2forms.ModelSelect2MultipleWidget): + """Widget for selecting multiple disciplines.""" search_fields = [ "title__icontains", ] @@ -513,6 +520,7 @@ def label_from_instance(self, obj): @deconstructible class FileSizeValidator(object): + """File size validator.""" message = _( "The current file %(size)s, which is too large. " "The maximum file size is %(allowed_size)s." @@ -520,6 +528,7 @@ class FileSizeValidator(object): code = "invalid_max_size" def __init__(self, *args, **kwargs): + """Initialize a new FileSizeValidator instance.""" self.max_size = VIDEO_MAX_UPLOAD_SIZE * 1024 * 1024 * 1024 # GO def __call__(self, value): @@ -905,6 +914,7 @@ def manage_more_required_fields(self): self.fields[field].required = True def set_nostaff_config(self): + """Set the configuration for non staff user.""" if self.is_staff is False: del self.fields["thumbnail"] @@ -923,6 +933,7 @@ def set_nostaff_config(self): ) def hide_default_language(self): + """Hide default language.""" if self.fields.get("description_%s" % settings.LANGUAGE_CODE): self.fields["description_%s" % settings.LANGUAGE_CODE].widget = ( forms.HiddenInput() @@ -931,10 +942,12 @@ def hide_default_language(self): self.fields["title_%s" % settings.LANGUAGE_CODE].widget = forms.HiddenInput() def remove_field(self, field): + """Remove a field from the form.""" if self.fields.get(field): del self.fields[field] def set_queryset(self): + """Set the queryset for the form fields.""" if self.current_user is not None: users_groups = self.current_user.owner.accessgroup_set.all() user_channels = ( @@ -990,7 +1003,7 @@ class Meta(object): "restrict_access_to_groups": AddAccessGroupWidget, "video": CustomClearableFileInput, "restrict_access_to_groups": AddAccessGroupWidget, - "password": forms.PasswordInput(), + "password": forms.PasswordInput(attrs={"autocomplete": "new-password"}), } initial = { "date_added": datetime.date.today(), @@ -1044,6 +1057,7 @@ def clean(self): cleaned_data["title_%s" % settings.LANGUAGE_CODE] = cleaned_data["title"] def __init__(self, *args, **kwargs): + """Initialize a new ChannelForm instance.""" self.is_staff = ( kwargs.pop("is_staff") if "is_staff" in kwargs.keys() else self.is_staff ) @@ -1091,6 +1105,7 @@ def __init__(self, *args, **kwargs): self.fields = add_describedby_attr(self.fields) class Meta(object): + """Define the ChannelForm metadata.""" model = Channel fields = "__all__" widgets = { @@ -1101,7 +1116,10 @@ class Meta(object): class ThemeForm(forms.ModelForm): + """Form class for Theme editing.""" + def __init__(self, *args, **kwargs): + """Initialize a new ThemeForm instance.""" super(ThemeForm, self).__init__(*args, **kwargs) if __FILEPICKER__: self.fields["headband"].widget = CustomFileWidget(type="image") @@ -1124,11 +1142,14 @@ def clean(self): cleaned_data["title_%s" % settings.LANGUAGE_CODE] = cleaned_data["title"] class Meta(object): + """Define the ThemeForm metadata.""" model = Theme fields = "__all__" class FrontThemeForm(ThemeForm): + """Form class for Theme editing in front.""" + def __init__(self, *args, **kwargs): self.THEME_FORM_FIELDS_HELP_TEXT = THEME_FORM_FIELDS_HELP_TEXT @@ -1141,19 +1162,25 @@ def __init__(self, *args, **kwargs): self.fields["parentId"].queryset = themes_queryset class Meta(object): + """Define the FrontThemeForm metadata.""" model = Theme fields = "__all__" class VideoPasswordForm(forms.Form): + """Form class for video password.""" + password = forms.CharField(label=_("Password"), widget=forms.PasswordInput()) def __init__(self, *args, **kwargs): + """Initialize a new VideoPasswordForm instance.""" super(VideoPasswordForm, self).__init__(*args, **kwargs) self.fields = add_placeholder_and_asterisk(self.fields) class VideoDeleteForm(forms.Form): + """Form class for video deletion.""" + agree = forms.BooleanField( label=_("I agree"), help_text=_("Delete video cannot be undo"), @@ -1161,43 +1188,59 @@ class VideoDeleteForm(forms.Form): ) def __init__(self, *args, **kwargs): + """Initialize a new VideoDeleteForm instance.""" super(VideoDeleteForm, self).__init__(*args, **kwargs) self.fields = add_placeholder_and_asterisk(self.fields) class TypeForm(forms.ModelForm): + """Form class for Type editing.""" + def __init__(self, *args, **kwargs): + """Initialize a new TypeForm instance.""" super(TypeForm, self).__init__(*args, **kwargs) if __FILEPICKER__: self.fields["icon"].widget = CustomFileWidget(type="image") class Meta(object): + """Define the TypeForm metadata.""" model = Type fields = "__all__" class DisciplineForm(forms.ModelForm): + """Form class for Discipline editing.""" + def __init__(self, *args, **kwargs): + """Initialize a new DisciplineForm instance.""" super(DisciplineForm, self).__init__(*args, **kwargs) if __FILEPICKER__: self.fields["icon"].widget = CustomFileWidget(type="image") class Meta(object): + """Define the DisciplineForm metadata.""" model = Discipline fields = "__all__" class VideoVersionForm(forms.ModelForm): + """Form class for VideoVersion editing.""" + def __init__(self, *args, **kwargs): + """Initialize a new VideoVersionForm instance.""" super(VideoVersionForm, self).__init__(*args, **kwargs) class Meta(object): + """Define the VideoVersionForm metadata.""" model = VideoVersion fields = "__all__" class NotesForm(forms.ModelForm): + """Form class for Notes editing.""" + def __init__(self, *args, **kwargs): + """Initialize a new NotesForm instance.""" super(NotesForm, self).__init__(*args, **kwargs) # self.fields["user"].widget = forms.HiddenInput() # self.fields["video"].widget = forms.HiddenInput() @@ -1206,12 +1249,16 @@ def __init__(self, *args, **kwargs): self.fields["note"].widget.attrs["rows"] = 5 class Meta(object): + """Define the NotesForm metadata.""" model = Notes fields = ["note"] class AdvancedNotesForm(forms.ModelForm): + """Form class for AdvancedNotes editing.""" + def __init__(self, *args, **kwargs): + """Initialize a new AdvancedNotesForm instance.""" super(AdvancedNotesForm, self).__init__(*args, **kwargs) # self.fields["user"].widget = forms.HiddenInput() self.fields["video"].widget = forms.HiddenInput() @@ -1227,12 +1274,16 @@ def __init__(self, *args, **kwargs): self.fields["status"].widget.attrs["class"] = "form-select" class Meta(object): + """Define the AdvancedNotesForm metadata.""" model = AdvancedNotes fields = ["video", "note", "timestamp", "status"] class NoteCommentsForm(forms.ModelForm): + """Form class for NoteComments editing.""" + def __init__(self, *args, **kwargs): + """Initialize a new NoteCommentsForm instance.""" super(NoteCommentsForm, self).__init__(*args, **kwargs) # self.fields["user"].widget = forms.HiddenInput() # self.fields["note"].widget = forms.HiddenInput() @@ -1248,5 +1299,6 @@ def __init__(self, *args, **kwargs): self.fields["status"].widget.attrs["class"] = "form-select" class Meta(object): + """Define the NoteCommentsForm metadata.""" model = NoteComments fields = ["comment", "status"] From 2d37cfc11691d50b6737ffe1679dfceea26fa1e0 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 29 Mar 2024 16:21:09 +0000 Subject: [PATCH 05/37] Fixup. Format code with Black --- pod/video/forms.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pod/video/forms.py b/pod/video/forms.py index 06502fcda4..80830d4610 100644 --- a/pod/video/forms.py +++ b/pod/video/forms.py @@ -431,6 +431,7 @@ class CustomClearableFileInput(ClearableFileInput): class OwnerWidget(s2forms.ModelSelect2Widget): """Widget for selecting a single owner.""" + search_fields = [ "username__icontains", "email__icontains", @@ -439,6 +440,7 @@ class OwnerWidget(s2forms.ModelSelect2Widget): class AddOwnerWidget(s2forms.ModelSelect2MultipleWidget): """Widget for selecting multiple owners.""" + search_fields = [ "username__icontains", "email__icontains", @@ -447,6 +449,7 @@ class AddOwnerWidget(s2forms.ModelSelect2MultipleWidget): class AddAccessGroupWidget(s2forms.ModelSelect2MultipleWidget): """Widget for selecting multiple access groups.""" + search_fields = [ "display_name__icontains", "code_name__icontains", @@ -455,6 +458,7 @@ class AddAccessGroupWidget(s2forms.ModelSelect2MultipleWidget): class ChannelWidget(s2forms.ModelSelect2MultipleWidget): """Widget for selecting multiple channels.""" + search_fields = [ "title__icontains", ] @@ -462,6 +466,7 @@ class ChannelWidget(s2forms.ModelSelect2MultipleWidget): class DisciplineWidget(s2forms.ModelSelect2MultipleWidget): """Widget for selecting multiple disciplines.""" + search_fields = [ "title__icontains", ] @@ -521,6 +526,7 @@ def label_from_instance(self, obj): @deconstructible class FileSizeValidator(object): """File size validator.""" + message = _( "The current file %(size)s, which is too large. " "The maximum file size is %(allowed_size)s." @@ -1106,6 +1112,7 @@ def __init__(self, *args, **kwargs): class Meta(object): """Define the ChannelForm metadata.""" + model = Channel fields = "__all__" widgets = { @@ -1143,6 +1150,7 @@ def clean(self): class Meta(object): """Define the ThemeForm metadata.""" + model = Theme fields = "__all__" @@ -1163,6 +1171,7 @@ def __init__(self, *args, **kwargs): class Meta(object): """Define the FrontThemeForm metadata.""" + model = Theme fields = "__all__" @@ -1204,6 +1213,7 @@ def __init__(self, *args, **kwargs): class Meta(object): """Define the TypeForm metadata.""" + model = Type fields = "__all__" @@ -1219,6 +1229,7 @@ def __init__(self, *args, **kwargs): class Meta(object): """Define the DisciplineForm metadata.""" + model = Discipline fields = "__all__" @@ -1232,6 +1243,7 @@ def __init__(self, *args, **kwargs): class Meta(object): """Define the VideoVersionForm metadata.""" + model = VideoVersion fields = "__all__" @@ -1250,6 +1262,7 @@ def __init__(self, *args, **kwargs): class Meta(object): """Define the NotesForm metadata.""" + model = Notes fields = ["note"] @@ -1275,6 +1288,7 @@ def __init__(self, *args, **kwargs): class Meta(object): """Define the AdvancedNotesForm metadata.""" + model = AdvancedNotes fields = ["video", "note", "timestamp", "status"] @@ -1300,5 +1314,6 @@ def __init__(self, *args, **kwargs): class Meta(object): """Define the NoteCommentsForm metadata.""" + model = NoteComments fields = ["comment", "status"] From 362125162ca640f9c5b6fa3297363b42d879b73c Mon Sep 17 00:00:00 2001 From: Ptitloup Date: Thu, 4 Apr 2024 14:56:42 +0200 Subject: [PATCH 06/37] [DONE] Update develop from master (#1091) * [DONE] Update README.md (#1090) link to pod main workflow * [DONE] Bump pillow from 10.2.0 to 10.3.0 (#1089) Bumps [pillow](https://github.com/python-pillow/Pillow) from 10.2.0 to 10.3.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/10.2.0...10.3.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- README.md | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5f31cde423..06cee884d6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Esup-Pod -[![Licence LGPL 3.0](https://img.shields.io/github/license/EsupPortail/Esup-Pod)](https://github.com/EsupPortail/Esup-Pod/blob/master/LICENSE) [![Testing Status](https://github.com/EsupPortail/Esup-Pod/actions/workflows/pod.yml/badge.svg)](https://github.com/EsupPortail/Esup-Pod/actions) [![Coverage Status](https://coveralls.io/repos/github/EsupPortail/Esup-Pod/badge.svg?branch=master)](https://coveralls.io/github/EsupPortail/Esup-Pod?branch=master) +[![Licence LGPL 3.0](https://img.shields.io/github/license/EsupPortail/Esup-Pod)](https://github.com/EsupPortail/Esup-Pod/blob/master/LICENSE) [![Testing Status](https://github.com/EsupPortail/Esup-Pod/actions/workflows/pod_main.yml/badge.svg)](https://github.com/EsupPortail/Esup-Pod/actions) [![Coverage Status](https://coveralls.io/repos/github/EsupPortail/Esup-Pod/badge.svg?branch=master)](https://coveralls.io/github/EsupPortail/Esup-Pod?branch=master) [FR] ## Plateforme de gestion de fichier vidéo diff --git a/requirements.txt b/requirements.txt index fd6445068c..4af0d86364 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -r requirements-encode.txt Django==3.2.25 django-ckeditor==6.3.0 -Pillow==10.2.0 +Pillow==10.3.0 django-tagging==0.5.0 django-modeltranslation==0.18.7 django-cas-client==1.5.3 From 176de77709757cef0fa364219a1797fd6954450b Mon Sep 17 00:00:00 2001 From: Olivier Bado-Faustin Date: Tue, 9 Apr 2024 09:31:21 +0200 Subject: [PATCH 07/37] [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 --- .env.dev-exemple | 2 +- .github/workflows/pod_dev.yml | 12 +- Makefile | 6 +- docker-compose-dev-with-volumes.yml | 2 +- docker-compose-full-dev-with-volumes-test.yml | 4 +- docker-compose-full-dev-with-volumes.yml | 4 +- dockerfile-dev-with-volumes/README.adoc | 2 +- make.bat | 16 +- pod/locale/fr/LC_MESSAGES/django.mo | Bin 181426 -> 184544 bytes pod/locale/fr/LC_MESSAGES/django.po | 149 +- pod/locale/nl/LC_MESSAGES/django.po | 97 +- pod/main/configuration.json | 11 +- pod/main/static/css/pod.css | 7 +- pod/video/templates/videos/add_video.html | 262 +-- pod/video/views.py | 1 - pod/video_encode_transcript/utils.py | 6 +- scripts/bbb-pod-live/bbb-pod-live.php | 1426 +++++++++-------- setup.cfg | 15 +- 18 files changed, 1098 insertions(+), 924 deletions(-) diff --git a/.env.dev-exemple b/.env.dev-exemple index 1d9d568b66..0f17d94e9c 100644 --- a/.env.dev-exemple +++ b/.env.dev-exemple @@ -2,7 +2,7 @@ DJANGO_SUPERUSER_USERNAME= DJANGO_SUPERUSER_PASSWORD= DJANGO_SUPERUSER_EMAIL= ### You can use internal registry -ELASTICSEARCH_TAG=elasticsearch:8.8.1 +ELASTICSEARCH_TAG=elasticsearch:8.13.0 NODE_TAG=node:19 PYTHON_TAG=python:3.9-buster REDIS_TAG=redis:alpine3.16 diff --git a/.github/workflows/pod_dev.yml b/.github/workflows/pod_dev.yml index c40cb71049..b3e729e53f 100644 --- a/.github/workflows/pod_dev.yml +++ b/.github/workflows/pod_dev.yml @@ -67,7 +67,7 @@ jobs: - name: Flake8 compliance run: | flake8 --max-complexity=7 --ignore=E501,W503,E203 --exclude .git,pod/*/migrations/*.py,*_settings.py - + ## Start remote encoding and transcoding test ## - name: Create settings local file run: | @@ -93,8 +93,8 @@ jobs: sudo rm -rf ./pod/log sudo rm -rf ./pod/static sudo rm -rf ./pod/node_modules - docker-compose -f ./docker-compose-full-dev-with-volumes-test.yml -p esup-pod build --build-arg ELASTICSEARCH_VERSION=$ELASTICSEARCH_TAG --build-arg NODE_VERSION=$NODE_TAG --build-arg PYTHON_VERSION=$PYTHON_TAG --no-cache - docker-compose -f ./docker-compose-full-dev-with-volumes-test.yml up --detach --force-recreate --always-recreate-deps + docker compose -f ./docker-compose-full-dev-with-volumes-test.yml -p esup-pod build --build-arg ELASTICSEARCH_VERSION=$ELASTICSEARCH_TAG --build-arg NODE_VERSION=$NODE_TAG --build-arg PYTHON_VERSION=$PYTHON_TAG --no-cache + docker compose -f ./docker-compose-full-dev-with-volumes-test.yml up --detach --force-recreate --always-recreate-deps - name: Sleep for 60 seconds to wait run server on pod back uses: jakejarvis/wait-action@master with: @@ -107,19 +107,19 @@ jobs: docker exec pod-back-with-volumes python manage.py loaddata pod/video/fixtures/sample_videos.json - name: run test in docker run: docker exec pod-back-with-volumes coverage run --append --source='.' manage.py test -v 3 --keepdb pod.video_encode_transcript.tests.test_remote_encode_transcode - + - name: Run pa11y-ci full run: docker exec pa11y-ci pa11y-ci -c /usr/src/app/dockerfile-dev-with-volumes/pa11y-ci/config.json - name: Run pa11y-ci mobile run: docker exec pa11y-ci pa11y-ci -c /usr/src/app/dockerfile-dev-with-volumes/pa11y-ci/config_mobile.json - + - name: show pa11y results run: cat pa11y-results.json - name: Stop containers if: always() - run: docker-compose -f ./docker-compose-full-dev-with-volumes-test.yml down + run: docker compose -f ./docker-compose-full-dev-with-volumes-test.yml down - name: delete unused file and change owner run: | sudo rm -f pod/custom/settings_local.py diff --git a/Makefile b/Makefile index af8e910193..81fac324de 100755 --- a/Makefile +++ b/Makefile @@ -89,9 +89,9 @@ createconfigs: # Use for docker run and docker exec commands -include .env.dev export -COMPOSE = docker-compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod -COMPOSE_FULL = docker-compose -f ./docker-compose-full-dev-with-volumes.yml -p esup-pod -COMPOSE_FULL_TEST = docker-compose -f ./docker-compose-full-dev-with-volumes-test.yml -p esup-pod +COMPOSE = docker compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod +COMPOSE_FULL = docker compose -f ./docker-compose-full-dev-with-volumes.yml -p esup-pod +COMPOSE_FULL_TEST = docker compose -f ./docker-compose-full-dev-with-volumes-test.yml -p esup-pod DOCKER_LOGS = docker logs -f #docker-start-build: diff --git a/docker-compose-dev-with-volumes.yml b/docker-compose-dev-with-volumes.yml index 9eb189ed34..1b58c29537 100755 --- a/docker-compose-dev-with-volumes.yml +++ b/docker-compose-dev-with-volumes.yml @@ -41,7 +41,7 @@ services: - ./.env.dev ports: - 6379:6379 - + # redis-commander: # container_name: redis-commander # hostname: redis-commander diff --git a/docker-compose-full-dev-with-volumes-test.yml b/docker-compose-full-dev-with-volumes-test.yml index 8913c02ebf..607ef863ea 100755 --- a/docker-compose-full-dev-with-volumes-test.yml +++ b/docker-compose-full-dev-with-volumes-test.yml @@ -31,7 +31,7 @@ services: env_file: - ./.env.dev volumes: *pod-volumes - + pod-transcript: container_name: pod-transcript-with-volumes build: @@ -83,7 +83,7 @@ services: depends_on: - pod-back volumes: *pod-volumes - + # redis-commander: # container_name: redis-commander # hostname: redis-commander diff --git a/docker-compose-full-dev-with-volumes.yml b/docker-compose-full-dev-with-volumes.yml index 52977c66a5..76c28915c8 100755 --- a/docker-compose-full-dev-with-volumes.yml +++ b/docker-compose-full-dev-with-volumes.yml @@ -31,7 +31,7 @@ services: env_file: - ./.env.dev volumes: *pod-volumes - + pod-transcript: container_name: pod-transcript-with-volumes build: @@ -74,7 +74,7 @@ services: - ./.env.dev ports: - 6379:6379 - + # redis-commander: # container_name: redis-commander # hostname: redis-commander diff --git a/dockerfile-dev-with-volumes/README.adoc b/dockerfile-dev-with-volumes/README.adoc index 8097bcc2ae..1bb70e6356 100755 --- a/dockerfile-dev-with-volumes/README.adoc +++ b/dockerfile-dev-with-volumes/README.adoc @@ -5,7 +5,7 @@ v1.2, 2023-08-30 :toc-title: Liste des rubriques :imagesdir: ./images -== Docker / docker-compose avec volumes sur la machine hôte +== Docker / docker compose avec volumes sur la machine hôte === Conteneur ElasticSearch http://localhost:9200 diff --git a/make.bat b/make.bat index 06872ef1e4..8db22f01bd 100644 --- a/make.bat +++ b/make.bat @@ -3,39 +3,39 @@ for /f "delims=" %%a in (.env.dev) do call set %%a if /i "%1"=="docker-build" ( echo "Suppression du repertoire node_modules" rmdir /s /q .\pod\node_modules - echo "Suppression du repertoire node_modules" + echo "Suppression du repertoire static" rmdir /s /q .\pod\static echo "Suppression du repertoire log" rmdir /s /q .\pod\log echo "Chargement des variables d'environnement depuis le fichier .env.dev" for /f "delims=" %%a in (.env.dev) do call set %%a echo "Debut du build" - docker-compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod build --build-arg ELASTICSEARCH_VERSION=%ELASTICSEARCH_TAG% --build-arg NODE_VERSION=%NODE_TAG% --build-arg PYTHON_VERSION=%PYTHON_TAG% --no-cache + docker compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod build --build-arg ELASTICSEARCH_VERSION=%ELASTICSEARCH_TAG% --build-arg NODE_VERSION=%NODE_TAG% --build-arg PYTHON_VERSION=%PYTHON_TAG% --no-cache echo "Debut du start" - docker-compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod up + docker compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod up echo "Vous devriez obtenir ce message une fois esup-pod lance" echo "pod-dev-with-volumes | Superuser created successfully." echo "Fin du build" ) else if /i "%1"=="docker-start" ( echo "Debut du start" - docker-compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod up + docker compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod up echo "Fin du start" ) else if /i "%1"=="docker-stop" ( echo "Debut du stop" - docker-compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod down -v + docker compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod down -v echo "Fin du stop" ) else if /i "%1"=="docker-reset" ( echo "Debut du reset" - docker-compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod down -v + docker compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod down -v echo "Suppression du repertoire node_modules" rmdir /s /q .\pod\node_modules - echo "Suppression du repertoire node_modules" + echo "Suppression du repertoire static" rmdir /s /q .\pod\static echo "Suppression du repertoire log" rmdir /s /q .\pod\log echo "Suppression du repertoire db_migrations" rmdir /s /q .\pod\db_migrations - echo "Suppression du repertoire db.sqlite3" + echo "Suppression du fichier db.sqlite3" del /s /q .\pod\db.sqlite3 echo "Suppression du repertoire media" rmdir /s /q .\pod\media diff --git a/pod/locale/fr/LC_MESSAGES/django.mo b/pod/locale/fr/LC_MESSAGES/django.mo index 877d2b86011a059ece96dfa7983f5cc4d0b71748..da947572a69fa276ae9d1f3744c062ce26d4adf8 100644 GIT binary patch delta 44245 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 delta 42127 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∨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 a<@%u|HiRo}_A|rpzGD0O(0;?3rN01Evp=l> diff --git a/pod/locale/fr/LC_MESSAGES/django.po b/pod/locale/fr/LC_MESSAGES/django.po index a4e0033ed4..ad78e4fe9e 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-03-12 12:01+0100\n" +"POT-Creation-Date: 2024-04-03 15:23+0000\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -228,7 +228,8 @@ msgstr "Fermer" #: pod/playlist/templates/playlist/add_or_edit.html #: pod/playlist/templates/playlist/delete.html #: pod/playlist/templates/playlist/protected-playlist-form.html -#: pod/playlist/views.py pod/recorder/templates/recorder/add_recording.html +#: pod/playlist/tests/test_views.py pod/playlist/views.py +#: pod/recorder/templates/recorder/add_recording.html #: pod/recorder/templates/recorder/record_delete.html pod/recorder/views.py #: pod/video/templates/channel/channel_edit.html #: pod/video/templates/videos/dashboard.html @@ -1266,7 +1267,7 @@ msgstr "Le document est privé." msgid "Documents" msgstr "Documents" -#: pod/completion/models.py +#: pod/completion/models.py pod/completion/tests/test_models.py msgid "Please enter a document." msgstr "Veuillez joindre un document." @@ -1888,6 +1889,7 @@ msgid "Cut the video" msgstr "Découper la vidéo" #: pod/cut/templates/video_cut.html pod/dressing/templates/video_dressing.html +#: pod/dressing/views.py #: pod/enrichment/templates/enrichment/group_enrichment.html #: pod/video/templates/videos/video_edit.html #: pod/video/templates/videos/video_page_content.html @@ -1960,14 +1962,18 @@ msgstr "" "Lors de la sauvegarde de votre découpe, un encodage est relancé pour " "remplacer l’ancien." +#: pod/cut/tests/test_views.py pod/cut/views.py +msgid "The cut was made." +msgstr "Découpe effectuée." + +#: pod/cut/tests/test_views.py +msgid "Please select values between 00:00:00 and 00:00:20." +msgstr "Veuillez renseigner des valeurs comprises entre 00:00:00 et 00:00:20." + #: pod/cut/views.py msgid "You cannot cut this video." msgstr "Vous ne pouvez pas découper cette vidéo." -#: pod/cut/views.py -msgid "The cut was made." -msgstr "Découpe effectuée." - #: pod/dressing/apps.py pod/dressing/models.py msgid "Video dressings" msgstr "Habillages de vidéos" @@ -2165,6 +2171,18 @@ msgstr "Sélectionner" msgid "Apply" msgstr "Appliquer" +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "You cannot edit this dressing." +msgstr "Vous ne pouvez pas éditer cet habillage." + +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "The dressing has been deleted." +msgstr "L’habillage a été supprimé." + +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "You cannot create a video dressing." +msgstr "Vous ne pouvez pas créer d’habillage de vidéo." + #: pod/dressing/views.py msgid "You cannot dress this video." msgstr "Vous ne pouvez pas habiller cette vidéo." @@ -2174,27 +2192,15 @@ msgstr "Vous ne pouvez pas habiller cette vidéo." msgid "Dress the video “%s”" msgstr "Habiller la vidéo « %s »" -#: pod/dressing/views.py -msgid "You cannot edit this dressing." -msgstr "Vous ne pouvez pas éditer cet habillage." - #: pod/dressing/views.py pod/import_video/views.py pod/live/views.py #: pod/meeting/views.py pod/video/views.py msgid "The changes have been saved." msgstr "Les modifications ont été sauvegardées." -#: pod/dressing/views.py -msgid "You cannot create a video dressing." -msgstr "Vous ne pouvez pas créer d’habillage de vidéo." - #: pod/dressing/views.py msgid "You cannot delete this dressing." msgstr "Vous ne pouvez pas supprimer cet habillage." -#: pod/dressing/views.py -msgid "The dressing has been deleted." -msgstr "L’habillage a été supprimé." - #: pod/dressing/views.py #, python-format msgid "Deleting the dressing “%s”" @@ -2579,8 +2585,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 "" @@ -2955,8 +2961,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 "" @@ -2970,8 +2976,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" @@ -2979,8 +2985,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" @@ -5993,16 +5999,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" " " @@ -6011,8 +6017,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" " " @@ -6036,8 +6042,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" @@ -6055,8 +6061,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" " " @@ -6569,7 +6575,7 @@ msgstr "Ce champ est requis." msgid "You cannot create a playlist named \"Favorites\"" msgstr "Vous ne pouvez créer une liste de lecture nommé \"Favoris\"" -#: pod/playlist/views.py +#: pod/playlist/tests/test_views.py pod/playlist/views.py msgid "The playlist has been deleted." msgstr "La liste de lecture a été supprimée." @@ -7109,8 +7115,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." @@ -7538,8 +7545,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 @@ -8079,13 +8086,27 @@ msgstr "Mention légale" #: pod/video/templates/videos/add_video.html msgid "" -"Please note: make sure that you have the necessary authorizations signed by " -"the speakers and that you respect the Intellectual Property Code before " -"publishing a video." +"Please note: make sure that you respect the Intellectual Property Code " +"before publishing a video." +msgstr "" +"Attention : assurez-vous de respecter le code de la propriété intellectuelle " +"avant de publier une vidéo." + +#: pod/video/templates/videos/add_video.html +msgid "" +"I confirm that I have the necessary authorizations signed by the parties " +"involved in the uploaded content." msgstr "" -"Attention : assurez-vous d’être en possession des autorisations de diffusion " -"signées par les intervenants et de respecter le Code de la Propriété " -"Intellectuelle avant de publier une vidéo." +"Je confirme que je dispose des autorisations nécessaires signées par les " +"parties concernées par ce média." + +#: pod/video/templates/videos/add_video.html +msgid "Selected file:" +msgstr "Fichier sélectionné :" + +#: pod/video/templates/videos/add_video.html +msgid "Undo" +msgstr "Annuler" #: pod/video/templates/videos/add_video.html msgid "Upload progress:" @@ -8105,6 +8126,10 @@ msgstr "" "la mise en ligne, elle reprendra automatiquement quand votre connexion sera " "de nouveau disponible." +#: pod/video/templates/videos/add_video.html +msgid "Upload" +msgstr "Téléverser" + #: pod/video/templates/videos/add_video.html #, python-format msgid "The file size must be lower than %(video_max_upload_size)s Go." @@ -8134,6 +8159,16 @@ msgstr "" msgid "Help for form fields" msgstr "Aide pour les champs de formulaire" +#: pod/video/templates/videos/add_video.html +msgid "Upload a media file first." +msgstr "Téléversez d’abord un média." + +#: pod/video/templates/videos/add_video.html +msgid "The file extension is not in the allowed extension:" +msgstr "" +"Cette extension de fichier n’est pas présente dans les extensions " +"autorisées :" + #: pod/video/templates/videos/card.html #: pod/video/templates/videos/card_select.html #: pod/video/templatetags/video_tags.py @@ -8396,8 +8431,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" @@ -9281,8 +9316,8 @@ msgid "Encoding" msgstr "Encodage" #: pod/video_encode_transcript/utils.py -msgid "The transcripting" -msgstr "La transcription" +msgid "The transcripting of content" +msgstr "La transcription du contenu" #: pod/video_encode_transcript/utils.py #, python-format diff --git a/pod/locale/nl/LC_MESSAGES/django.po b/pod/locale/nl/LC_MESSAGES/django.po index 1e3a7aecd8..1f3a7216d1 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-03-12 12:01+0100\n" +"POT-Creation-Date: 2024-04-03 15:23+0000\n" "PO-Revision-Date: 2023-06-08 14:37+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -224,7 +224,8 @@ msgstr "" #: pod/playlist/templates/playlist/add_or_edit.html #: pod/playlist/templates/playlist/delete.html #: pod/playlist/templates/playlist/protected-playlist-form.html -#: pod/playlist/views.py pod/recorder/templates/recorder/add_recording.html +#: pod/playlist/tests/test_views.py pod/playlist/views.py +#: pod/recorder/templates/recorder/add_recording.html #: pod/recorder/templates/recorder/record_delete.html pod/recorder/views.py #: pod/video/templates/channel/channel_edit.html #: pod/video/templates/videos/dashboard.html @@ -1201,7 +1202,7 @@ msgstr "" msgid "Documents" msgstr "" -#: pod/completion/models.py +#: pod/completion/models.py pod/completion/tests/test_models.py msgid "Please enter a document." msgstr "" @@ -1798,6 +1799,7 @@ msgid "Cut the video" msgstr "" #: pod/cut/templates/video_cut.html pod/dressing/templates/video_dressing.html +#: pod/dressing/views.py #: pod/enrichment/templates/enrichment/group_enrichment.html #: pod/video/templates/videos/video_edit.html #: pod/video/templates/videos/video_page_content.html @@ -1862,12 +1864,16 @@ msgstr "" msgid "When saving your cut, an encoding is restarted to replace the old one." msgstr "" -#: pod/cut/views.py -msgid "You cannot cut this video." +#: pod/cut/tests/test_views.py pod/cut/views.py +msgid "The cut was made." +msgstr "" + +#: pod/cut/tests/test_views.py +msgid "Please select values between 00:00:00 and 00:00:20." msgstr "" #: pod/cut/views.py -msgid "The cut was made." +msgid "You cannot cut this video." msgstr "" #: pod/dressing/apps.py pod/dressing/models.py @@ -2060,6 +2066,18 @@ msgstr "" msgid "Apply" msgstr "" +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "You cannot edit this dressing." +msgstr "" + +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "The dressing has been deleted." +msgstr "" + +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "You cannot create a video dressing." +msgstr "" + #: pod/dressing/views.py msgid "You cannot dress this video." msgstr "" @@ -2069,27 +2087,15 @@ msgstr "" msgid "Dress the video “%s”" msgstr "" -#: pod/dressing/views.py -msgid "You cannot edit this dressing." -msgstr "" - #: pod/dressing/views.py pod/import_video/views.py pod/live/views.py #: pod/meeting/views.py pod/video/views.py msgid "The changes have been saved." msgstr "" -#: pod/dressing/views.py -msgid "You cannot create a video dressing." -msgstr "" - #: pod/dressing/views.py msgid "You cannot delete this dressing." msgstr "" -#: pod/dressing/views.py -msgid "The dressing has been deleted." -msgstr "" - #: pod/dressing/views.py #, python-format msgid "Deleting the dressing “%s”" @@ -2787,15 +2793,15 @@ 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 "" #: 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 "" #: pod/import_video/views.py @@ -5674,8 +5680,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)sHello,

\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" @@ -6173,7 +6179,7 @@ msgstr "" msgid "You cannot create a playlist named \"Favorites\"" msgstr "" -#: pod/playlist/views.py +#: pod/playlist/tests/test_views.py pod/playlist/views.py msgid "The playlist has been deleted." msgstr "" @@ -6683,8 +6689,8 @@ msgstr "" #: 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 "" #: pod/recorder/templates/recorder/link_record.html @@ -7558,9 +7564,22 @@ msgstr "" #: pod/video/templates/videos/add_video.html msgid "" -"Please note: make sure that you have the necessary authorizations signed by " -"the speakers and that you respect the Intellectual Property Code before " -"publishing a video." +"Please note: make sure that you respect the Intellectual Property Code " +"before publishing a video." +msgstr "" + +#: pod/video/templates/videos/add_video.html +msgid "" +"I confirm that I have the necessary authorizations signed by the parties " +"involved in the uploaded content." +msgstr "" + +#: pod/video/templates/videos/add_video.html +msgid "Selected file:" +msgstr "" + +#: pod/video/templates/videos/add_video.html +msgid "Undo" msgstr "" #: pod/video/templates/videos/add_video.html @@ -7577,6 +7596,10 @@ msgid "" "upload, it will resume automatically when your connection is available again." msgstr "" +#: pod/video/templates/videos/add_video.html +msgid "Upload" +msgstr "" + #: pod/video/templates/videos/add_video.html #, python-format msgid "The file size must be lower than %(video_max_upload_size)s Go." @@ -7601,6 +7624,14 @@ msgstr "" msgid "Help for form fields" msgstr "" +#: pod/video/templates/videos/add_video.html +msgid "Upload a media file first." +msgstr "" + +#: pod/video/templates/videos/add_video.html +msgid "The file extension is not in the allowed extension:" +msgstr "" + #: pod/video/templates/videos/card.html #: pod/video/templates/videos/card_select.html #: pod/video/templatetags/video_tags.py @@ -8704,7 +8735,7 @@ msgid "Encoding" msgstr "Videocodering" #: pod/video_encode_transcript/utils.py -msgid "The transcripting" +msgid "The transcripting of content" msgstr "" #: pod/video_encode_transcript/utils.py diff --git a/pod/main/configuration.json b/pod/main/configuration.json index cab82eb8c2..5fd1d395aa 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -4299,13 +4299,18 @@ "pod_version_init": "3.1" }, "SECURE_SSL_REDIRECT": { - "default_value": "not DEBUG", + "default_value": "False", "description": { "en": [ - "" + "Unless your site should be available over both SSL and non-SSL ", + "connections, you may want to either set this setting True ", + "or configure a load balancer or reverse-proxy server ", + "to redirect all connections to HTTPS." ], "fr": [ - "" + "À moins que votre site ne doive être disponible sur des connexions SSL et non SSL,", + " vous souhaiterez probablement définir ce paramètre sur True ou configurer un", + " load balancer ou reverse-proxy pour rediriger toutes les connexions vers HTTPS." ] }, "pod_version_end": "", diff --git a/pod/main/static/css/pod.css b/pod/main/static/css/pod.css index 8cebdec962..27632492f2 100755 --- a/pod/main/static/css/pod.css +++ b/pod/main/static/css/pod.css @@ -1544,7 +1544,7 @@ table .alert.alert-danger.btn.pod-btn-social { font-size: 11px; } -/* Upload page */ +/** Upload page **/ .form-group.hide-on-processing { border: dotted var(--pod-link-color); @@ -1553,6 +1553,11 @@ table .alert.alert-danger.btn.pod-btn-social { width: fit-content; } +/* Drag over styles */ +.dragover .form-group.hide-on-processing { + border: solid var(--bs-form-valid-border-color); +} + /** Esup-Pod callout messages **/ .pod-callout { padding: 0.7rem 1rem; diff --git a/pod/video/templates/videos/add_video.html b/pod/video/templates/videos/add_video.html index a72b73585d..d9ca01dd0e 100644 --- a/pod/video/templates/videos/add_video.html +++ b/pod/video/templates/videos/add_video.html @@ -11,58 +11,104 @@ {% block page_title %}{% if form.instance.title %}{% trans "Editing the video" %} "{{form.instance.title}}"{% else %}{% trans "Choose an audio or video file to upload" %}{% endif %}{% endblock %} {% block collapse_page_aside %} -{% if access_not_allowed != True %} -{{block.super}} -{% endif %} + {% if access_not_allowed != True %} + {{block.super}} + {% endif %} {% endblock collapse_page_aside %} +{% block page_extra_head %} + + +{% endblock page_extra_head %} {% block page_content %} {% if restricted_to_staff and not user.is_staff %} -

-  {% trans "Access to adding video has been restricted. If you want to add videos on the platform, please" %} {% trans 'contact us' %} -

+

+  {% trans "Access to adding video has been restricted. If you want to add videos on the platform, please" %} {% trans 'contact us' %} +

{% else %} -
- {% csrf_token %} -
-

 {% trans "Legal notice" %}

-

{% trans "Please note: make sure that you have the necessary authorizations signed by the speakers and that you respect the Intellectual Property Code before publishing a video." %}

-
+
+ {% csrf_token %} +
+

 {% trans "Legal notice" %}

+

{% trans "Please note: make sure that you respect the Intellectual Property Code before publishing a video." %}

+
+ + +
+
-

 {% trans "Uploading" %}

-
-
- -
-
- - -
+
+

 {% trans "Uploading" %}

+
+
+ +
+
+ {% trans "Selected file:"%} + + +
+
+ + +
+
- {% if TRANSCRIPT|length > 0 %} -
- - {% trans "Select an available language if you want to transcribe the audio." %} -
- {% endif %} + {% if TRANSCRIPT|length > 0 %} + + {% trans "Select an available language if you want to transcribe the audio." %} + {% endif %} -

{% trans "Do not leave the page if you encounter a connection problem during the upload, it will resume automatically when your connection is available again." %}

+

{% trans "Do not leave the page if you encounter a connection problem during the upload, it will resume automatically when your connection is available again." %}

-
-{% if slug %} - -  {% trans "Back to video edit"%} - -{% endif %} +
+ +
+ + {% if slug %} + +  {% trans "Back to video edit" %} + + {% endif %} {% endif %} {% endblock page_content %} @@ -106,7 +152,6 @@

{% trans "Help for form fi {% endblock page_aside %} - {% block more_script %} @@ -119,17 +164,16 @@

{% trans "Help for form fi - - - -{{form.media}} - - +{{form.media}} {% endblock more_script %} diff --git a/pod/video/views.py b/pod/video/views.py index 1bd6450829..db5472306c 100644 --- a/pod/video/views.py +++ b/pod/video/views.py @@ -742,7 +742,6 @@ def bulk_update_fields(request, videos_list, update_fields): fields_errors = [] for video in videos_list: - if "owner" in update_fields: new_owner = User.objects.get(pk=request.POST.get("owner")) or None if change_owner(video.id, new_owner): diff --git a/pod/video_encode_transcript/utils.py b/pod/video_encode_transcript/utils.py index 7ba5d2fc58..b7e55de5e3 100644 --- a/pod/video_encode_transcript/utils.py +++ b/pod/video_encode_transcript/utils.py @@ -143,7 +143,7 @@ def send_email(msg, video_id): def send_email_transcript(video_to_encode): """Send email on transcripting completion.""" - subject_prefix = _("The transcripting") + subject_prefix = _("The transcripting of content") send_notification_email(video_to_encode, subject_prefix) @@ -171,13 +171,13 @@ def send_notification_email(video_to_encode, subject_prefix): % { "content_type": ( _("The content") - if subject_prefix == _("The transcripting") + if subject_prefix == _("The transcripting of content") else _("The video") ), "content_title": "%s" % video_to_encode.title, "action": ( _("automatically transcripted") - if (subject_prefix == _("The transcripting")) + if (subject_prefix == _("The transcripting of content")) else _("encoded to Web formats") ), "site_title": __TITLE_SITE__, diff --git a/scripts/bbb-pod-live/bbb-pod-live.php b/scripts/bbb-pod-live/bbb-pod-live.php index 8f7d79b56d..841d2332c3 100644 --- a/scripts/bbb-pod-live/bbb-pod-live.php +++ b/scripts/bbb-pod-live/bbb-pod-live.php @@ -2,62 +2,62 @@ /************* DEBUT PARAMETRAGE *************/ /* PARAMETRAGE NECESSAIRE POUR BBB-POD-LIVE */ // Application en mode débogage (true - on logue toutes les lignes) ou en production (false - on logue seulement les erreurs et infos). -define ("DEBUG", true); +define("DEBUG", true); // Répertoire de base de l'application et avoir suffisamment d'espace disque pour l'enregistrement de quelques vidéos (stockage temporaire) // !!! Ce répertoire doit être sur un disque dur de la machine serveur. -define ("PHYSICAL_BASE_ROOT","/home/pod/bbb-pod-live/"); +define("PHYSICAL_BASE_ROOT", "/home/pod/bbb-pod-live/"); // Constante permettant de définir le chemin physique du répertoire contenant les logs applicatifs. // !!! L'arborescence doit être sur un disque dur local de la machine serveur. -define ("PHYSICAL_LOG_ROOT", "/home/pod/bbb-pod-live/logs/"); +define("PHYSICAL_LOG_ROOT", "/home/pod/bbb-pod-live/logs/"); // Mail de l'administrateur de BBB-POD-LIVE, qui recevra les mails en cas de démarrage de live ou d'erreur -define ("ADMIN_EMAIL", "admin@univ.fr"); +define("ADMIN_EMAIL", "admin@univ.fr"); // Hostname de ce serveur BBB-POD-LIVE (utile pour Redis et le chat) -define ("SERVER_HOSTNAME", "server.univ.fr"); +define("SERVER_HOSTNAME", "server.univ.fr"); // Nombre de serveurs BBB-POD-LIVE -define ("NUMBER_SERVERS", 1); +define("NUMBER_SERVERS", 1); // Numéro unique de ce serveur dans la liste des serveurs BBB-POD-LIVE // Par exemple : s'il y a 2 serveurs BBB-POD-LIVE (NUMBER_SERVERS = 2), alors un serveur devra avoir SERVER_NUMBER=1 et l'autre SERVER_NUMBER=2 -define ("SERVER_NUMBER", 1); +define("SERVER_NUMBER", 1); // Nombre de directs gérés par ce serveur (à adapter selon les ressources du serveur) -define ("NUMBER_LIVES", 3); +define("NUMBER_LIVES", 3); /* PARAMETRAGE NECESSAIRE POUR BigBlueButton-liveStreaming */ // URL du serveur BigBlueButton/Scalelite, avec la notion d'API -define ("BBB_URL", "https://bbb.univ.fr/bigbluebutton/api"); +define("BBB_URL", "https://bbb.univ.fr/bigbluebutton/api"); // Clé secrète du serveur BigBlueButton/Scalelite -define ("BBB_SECRET", "xxxxxxxxxxxxx"); +define("BBB_SECRET", "xxxxxxxxxxxxx"); // Résolution pour diffuser / télécharger au format WxH (Défaut: 1920x1080). cf. BBB_RESOLUTION -define ("BBB_RESOLUTION", "1280x720"); +define("BBB_RESOLUTION", "1280x720"); // Bitrate de la vidéo (Défaut: 4000). cf. FFMPEG_STREAM_VIDEO_BITRATE -define ("FFMPEG_STREAM_VIDEO_BITRATE", "3000"); +define("FFMPEG_STREAM_VIDEO_BITRATE", "3000"); // Threads utilisés pour le flux (Défaut: 0). 0 signifie auto. cf. FFMPEG_STREAM_THREADS -define ("FFMPEG_STREAM_THREADS", "0"); +define("FFMPEG_STREAM_THREADS", "0"); // Serveur RTMP qui va gérer les directs pour ce serveur BBB-POD-LIVE // Format, sans authentification : rtmp://serveurRTMP.domaine.fr:port/application/ // Format, avec authentification : rtmp://user@password:serveur.domaine.fr:port/application/ // Exemple : rtmp://live.univ.fr:1935/live/ cf. BBB_STREAM_URL -define ("BBB_STREAM_URL", "rtmp://live.univ.fr/live/"); +define("BBB_STREAM_URL", "rtmp://live.univ.fr/live/"); // Mot de passe des participants cf. BBB_ATTENDEE_PASSWORD // Doit être défini comme le mot de passe du participant de Moodle / Greenlight ou de tout autre frontend pour permettre la participation via leurs liens -define ("BBB_ATTENDEE_PASSWORD", "xxxxxxx"); +define("BBB_ATTENDEE_PASSWORD", "xxxxxxx"); // Mot de passe des modérateurs cf. BBB_ATTENDEE_PASSWORD // Doit être défini comme le mot de passe du modérateur de Moodle / Greenlight ou de tout autre frontend pour permettre la participation via leurs liens -define ("BBB_MODERATOR_PASSWORD", "xxxxxxx"); +define("BBB_MODERATOR_PASSWORD", "xxxxxxx"); /* PARAMETRAGE NECESSAIRE POUR POD */ // Flux HLS, dépend de la configuration du serveur RTMP Nginx utilisé -define ("POD_HLS_STREAM", "https://live.univ.fr/hls/"); +define("POD_HLS_STREAM", "https://live.univ.fr/hls/"); // URL du serveur Pod -define ("POD_URL", "https://pod.univ.fr"); +define("POD_URL", "https://pod.univ.fr"); // Token de sécurité de Pod, utile pour attaquer Pod via les API Rest (cf. administration de Pod / Jeton) -define ("POD_TOKEN", "xxxxxxxxxxxxxxx"); +define("POD_TOKEN", "xxxxxxxxxxxxxxx"); // Identifiant du bâtiment POD (au sens live/building de POD) de rattachement des diffuseurs créés par bbb-pod-live -define ("POD_ID_BUILDING", 1); +define("POD_ID_BUILDING", 1); // Répertoire dans lequel copier les fichiers vidéo générés par BigBlueButton-liveStreaming // Ce répertoire - typiquement un partage NFS - doit être accessible aussi par POD et correspondre à DEFAULT_BBB_PATH du fichier settings_local.py. // Si ce n'est pas possible, laisser ce champ vide et positionner USE_BBB_LIVE_DOWNLOADING = False dans le settings_local de POD. -define ("POD_DEFAULT_BBB_PATH", "/data/www/pod/bbb-recorder/"); +define("POD_DEFAULT_BBB_PATH", "/data/www/pod/bbb-recorder/"); /************* FIN PARAMETRAGE *************/ @@ -70,33 +70,32 @@ /********** Début de la phase principale**********/ try { - // Gestion de la timezone - date_default_timezone_set('Europe/Paris'); - - // Décalage permettant d'éviter que chaque serveur n'exécute ce script en même temps. - // Cela sert aussi de "load balancer" simpliste : chaque serveur va prendre en charge les demandes de directs sur la période de décalage. - $delay = (SERVER_NUMBER - 1) * round(60 / NUMBER_SERVERS); - sleep($delay); - - writeLog("----------" . date('Y-m-d H:i:s') . "----------", "DEBUG"); - - // Création des répertoires, des fichiers compose et configuration initiale de ces derniers pour le plugin BigBlueButton-liveStreaming. - // Un répertoire, par plugin BigBlueButton-liveStreaming, est créé, selon le nombre de directs que gère ce serveur (cf. NUMBER_LIVES). - configureInitialBigBlueButtonLiveStreaming(); - - // Démarrage des directs, si des usagers en ont fait la demande dans Pod - startLives(); - - // Arrêt des directs si les sessions BigBlueButton correspondante s ont été arrêtées - stopLives(); -} -catch(Exception $e) { - $GLOBALS["txtErrorInScript"] .= $e->getMessage(); + // Gestion de la timezone + date_default_timezone_set('Europe/Paris'); + + // Décalage permettant d'éviter que chaque serveur n'exécute ce script en même temps. + // Cela sert aussi de "load balancer" simpliste : chaque serveur va prendre en charge les demandes de directs sur la période de décalage. + $delay = (SERVER_NUMBER - 1) * round(60 / NUMBER_SERVERS); + sleep($delay); + + writeLog("----------" . date('Y-m-d H:i:s') . "----------", "DEBUG"); + + // Création des répertoires, des fichiers compose et configuration initiale de ces derniers pour le plugin BigBlueButton-liveStreaming. + // Un répertoire, par plugin BigBlueButton-liveStreaming, est créé, selon le nombre de directs que gère ce serveur (cf. NUMBER_LIVES). + configureInitialBigBlueButtonLiveStreaming(); + + // Démarrage des directs, si des usagers en ont fait la demande dans Pod + startLives(); + + // Arrêt des directs si les sessions BigBlueButton correspondante s ont été arrêtées + stopLives(); +} catch (Exception $e) { + $GLOBALS["txtErrorInScript"] .= $e->getMessage(); } // Envoi d'un message à l'administrateur en cas d'erreur de script if ($GLOBALS["txtErrorInScript"] != "") { - sendEmail("[BBB-POD-LIVE] Erreur rencontrée", $GLOBALS["txtErrorInScript"]); + sendEmail("[BBB-POD-LIVE] Erreur rencontrée", $GLOBALS["txtErrorInScript"]); } /********** Fin de la phase principale**********/ @@ -105,32 +104,32 @@ * Un répertoire, par nombre de directs gérés par ce serveur (cf. NUMBER_LIVES), sera créé sous la forme bbb-live-streaming+incrémental. * Le fichier compose.yml sera copié depuis le répertoire courant (fichier docker-compose.default.yml). */ -function configureInitialBigBlueButtonLiveStreaming() { - writeLog("----- Configuration des plugins nécessaires : configureBigBlueButtonLiveStreaming()-----", "DEBUG"); - // Création des répertoires et des fichiers compose pour le plugin BigBlueButton-liveStreaming - for ($i = 1; $i <= NUMBER_LIVES; $i++) { - // Définition du répertoire - $directoryLiveStreaming = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming$i/"; - writeLog("Vérification pour le direct $i : $directoryLiveStreaming", "DEBUG"); - // Définition du fichier compose.yml dans ce répertoire - $fichierCompose = $directoryLiveStreaming . "docker-compose.yml"; - // Création du répertoire et du fichier la 1° fois - if (! file_exists($fichierCompose)) { - // Création du répertoire - writeLog(" + Création du répertoire $directoryLiveStreaming", "DEBUG"); - @mkdir("$directoryLiveStreaming", 0755); - // Téléchargement du fichier compose depuis Github - writeLog(" + Copie du fichier docker-compose.default.yml du répertoire courant", "DEBUG"); - $cmdCp = "cp ./docker-compose.default.yml $fichierCompose"; - exec("$cmdCp 2>&1", $aRetourVerificationCp, $sRetourVerificationCp); - if ($sRetourVerificationCp == 0) { - writeLog(" + Copie du fichier $fichierCompose réalisée", "DEBUG"); - } - else { - writeLog(" - Commande '$cmdCp' : $aRetourVerificationCp[0]", "ERROR", __FILE__, __LINE__); - } - } - } +function configureInitialBigBlueButtonLiveStreaming() +{ + writeLog("----- Configuration des plugins nécessaires : configureBigBlueButtonLiveStreaming()-----", "DEBUG"); + // Création des répertoires et des fichiers compose pour le plugin BigBlueButton-liveStreaming + for ($i = 1; $i <= NUMBER_LIVES; $i++) { + // Définition du répertoire + $directoryLiveStreaming = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming$i/"; + writeLog("Vérification pour le direct $i : $directoryLiveStreaming", "DEBUG"); + // Définition du fichier compose.yml dans ce répertoire + $fichierCompose = $directoryLiveStreaming . "docker-compose.yml"; + // Création du répertoire et du fichier la 1° fois + if (! file_exists($fichierCompose)) { + // Création du répertoire + writeLog(" + Création du répertoire $directoryLiveStreaming", "DEBUG"); + @mkdir("$directoryLiveStreaming", 0755); + // Téléchargement du fichier compose depuis Github + writeLog(" + Copie du fichier docker-compose.default.yml du répertoire courant", "DEBUG"); + $cmdCp = "cp ./docker-compose.default.yml $fichierCompose"; + exec("$cmdCp 2>&1", $aRetourVerificationCp, $sRetourVerificationCp); + if ($sRetourVerificationCp == 0) { + writeLog(" + Copie du fichier $fichierCompose réalisée", "DEBUG"); + } else { + writeLog(" - Commande '$cmdCp' : $aRetourVerificationCp[0]", "ERROR", __FILE__, __LINE__); + } + } + } } /** @@ -138,114 +137,112 @@ function configureInitialBigBlueButtonLiveStreaming() { * Pour cela, on utilise l'API Rest de Pod. * Cette procédure permet d'identifier si un slot est disponible pour être utilisé pour lancer un direct. */ -function startLives() { - writeLog("-----Démarrage des directs : startLives()-----", "DEBUG"); - - // Recherche si des lives sont en cours - $cmdStatus1 = "curl --silent -H 'Content-Type: application/json' "; - $cmdStatus1 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmdStatus1 .= "-X GET " . checkEndWithoutSlash(POD_URL). "/rest/bbb_livestream/?status=1"; - - $verificationStatus1 = exec("$cmdStatus1 2>&1", $aRetourVerificationStatus1, $sRetourVerificationStatus1); - - writeLog("Recherche si des lives sont en cours", "DEBUG"); - - // En cas d'erreur, le code de retour est différent de 0 - if ($sRetourVerificationStatus1 == 0) { - writeLog(" + Commande '$cmdStatus1' : $aRetourVerificationStatus1[0]", "DEBUG"); - - $oListeSessions = json_decode($aRetourVerificationStatus1[0]); - // Recherche des lives existants en cours, sauvegardés dans Pod - for ($i = 0; $i < $oListeSessions->count; $i++) { - // Identifiant du live dans Pod - $idLive = $oListeSessions->results[$i]->id; - // Dans Pod, l'information est sauvegardé sous la forme NUMERO_SERVEUR/NUMERO_REPERTOIRE_bbb_live_streaming - $server = $oListeSessions->results[$i]->server; - // Le live est il déjà en cours sur un des serveurs BBB-POD-LIVE ? - $status = $oListeSessions->results[$i]->status; - // Utilisateur ayant lancé ce live - $user = $oListeSessions->results[$i]->user; - // Prise en compte seulement des lives en cours de ce serveur - if (($status == 1) && (strpos("$server", SERVER_NUMBER . "/") !== false)) { - // Sauvegarde du NUMERO_REPERTOIRE_bbb_live_streaming - $processInProgress = str_replace(SERVER_NUMBER . "/", "", $server); - // Utilisation d'une classe standard - $liveInProgress = new stdClass(); - $liveInProgress->id = $idLive; - $liveInProgress->idBbbLiveStreaming = $processInProgress; - // Ajout de cet objet au tableau des lives en cours sur ce serveur - $GLOBALS["livesInProgressOnThisServer"][] = $liveInProgress; - writeLog(" => Le live $idLive de $user est toujours en cours sur le serveur/bbb_live_streaming : $server.", "DEBUG"); - } - } - } - else { - writeLog(" + Commande '$cmdStatus1' : $sRetourVerificationStatus1[0]", "ERROR", __FILE__, __LINE__); - } - - // Recherche si des utilisateurs ont lancé des lives depuis Pod - $cmdStatus0 = "curl --silent -H 'Content-Type: application/json' "; - $cmdStatus0 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmdStatus0 .= "-X GET " . checkEndWithoutSlash(POD_URL) . "/rest/bbb_livestream/?status=0"; - - $verificationStatus0 = exec("$cmdStatus0 2>&1", $aRetourVerificationStatus0, $sRetourVerificationStatus0); - - writeLog("Recherche si des utilisateurs ont lancé des lives depuis Pod", "DEBUG"); - - // En cas d'erreur, le code de retour est différent de 0 - if ($sRetourVerificationStatus0 == 0) { - writeLog(" + Commande '$cmdStatus0' : $aRetourVerificationStatus0[0]", "DEBUG"); - - $oListeSessions = json_decode($aRetourVerificationStatus0[0]); - // Recherche des nouvelles demandes de lives, sauvegardées dans Pod - for ($i = 0; $i < $oListeSessions->count; $i++) { - // Identifiant du live BBB dans Pod - $idLive = $oListeSessions->results[$i]->id; - // Adresse de la session dans Pod - $urlMeeting = $oListeSessions->results[$i]->meeting; - // Nom du serveur/processus déjà en charge de ce live - $server = $oListeSessions->results[$i]->server; - // Le live est il déjà en cours sur un des serveurs BBB-POD-LIVE ? - $status = $oListeSessions->results[$i]->status; - // Utilisateur ayant lancé ce live - $user = $oListeSessions->results[$i]->user; - // Identifiant du répertoire bbb-live-streaming qui s'occupera de réaliser le live, si disponible - $idBbbLiveStreaming = 0; - // Recherche si ce serveur peut encore lancer un direct - for ($j = 1; $j <= NUMBER_LIVES; $j++) { - // Variable de travail - $idBbbLiveStreamingUsed = false; - foreach ($GLOBALS["livesInProgressOnThisServer"] as $ligneLiveInProgressOnThisServer) { - // Cet idBbbLiveStreaming est déjà utilisé - if ($ligneLiveInProgressOnThisServer->idBbbLiveStreaming == $j) { - $idBbbLiveStreamingUsed = true; - } - } - // Le slot idBbbLiveStreaming est non utilisé - if (! $idBbbLiveStreamingUsed) { - // Un slot est disponible - $idBbbLiveStreaming = $j; - // Ajout de l'information aux lives en cours sur ce serveur - $liveInProgress2 = new stdClass(); - $liveInProgress2->id = $idLive; - $liveInProgress2->idBbbLiveStreaming = $idBbbLiveStreaming; - $GLOBALS["livesInProgressOnThisServer"][] = $liveInProgress2; - break; - } - } - // Un slot est disponible sur ce serveur pour réaliser un live ? - if ($idBbbLiveStreaming == 0) { - writeLog(" => Impossible de lancer le live $idLive de $user sur ce serveur : il y a déjà " . NUMBER_LIVES . " directs qui sont gérés par ce serveur.", "INFO"); - } - else { - writeLog(" => Lancement du live $idLive de $user, via bbb-live-streaming$idBbbLiveStreaming", "INFO"); - configureAndStartLive($idLive, $urlMeeting, $idBbbLiveStreaming); - } - } - } - else { - writeLog(" + Commande '$cmdStatus0' : $sRetourVerificationStatus0[0]", "ERROR", __FILE__, __LINE__); - } +function startLives() +{ + writeLog("-----Démarrage des directs : startLives()-----", "DEBUG"); + + // Recherche si des lives sont en cours + $cmdStatus1 = "curl --silent -H 'Content-Type: application/json' "; + $cmdStatus1 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmdStatus1 .= "-X GET " . checkEndWithoutSlash(POD_URL). "/rest/bbb_livestream/?status=1"; + + $verificationStatus1 = exec("$cmdStatus1 2>&1", $aRetourVerificationStatus1, $sRetourVerificationStatus1); + + writeLog("Recherche si des lives sont en cours", "DEBUG"); + + // En cas d'erreur, le code de retour est différent de 0 + if ($sRetourVerificationStatus1 == 0) { + writeLog(" + Commande '$cmdStatus1' : $aRetourVerificationStatus1[0]", "DEBUG"); + + $oListeSessions = json_decode($aRetourVerificationStatus1[0]); + // Recherche des lives existants en cours, sauvegardés dans Pod + for ($i = 0; $i < $oListeSessions->count; $i++) { + // Identifiant du live dans Pod + $idLive = $oListeSessions->results[$i]->id; + // Dans Pod, l'information est sauvegardé sous la forme NUMERO_SERVEUR/NUMERO_REPERTOIRE_bbb_live_streaming + $server = $oListeSessions->results[$i]->server; + // Le live est il déjà en cours sur un des serveurs BBB-POD-LIVE ? + $status = $oListeSessions->results[$i]->status; + // Utilisateur ayant lancé ce live + $user = $oListeSessions->results[$i]->user; + // Prise en compte seulement des lives en cours de ce serveur + if (($status == 1) && (strpos("$server", SERVER_NUMBER . "/") !== false)) { + // Sauvegarde du NUMERO_REPERTOIRE_bbb_live_streaming + $processInProgress = str_replace(SERVER_NUMBER . "/", "", $server); + // Utilisation d'une classe standard + $liveInProgress = new stdClass(); + $liveInProgress->id = $idLive; + $liveInProgress->idBbbLiveStreaming = $processInProgress; + // Ajout de cet objet au tableau des lives en cours sur ce serveur + $GLOBALS["livesInProgressOnThisServer"][] = $liveInProgress; + writeLog(" => Le live $idLive de $user est toujours en cours sur le serveur/bbb_live_streaming : $server.", "DEBUG"); + } + } + } else { + writeLog(" + Commande '$cmdStatus1' : $sRetourVerificationStatus1[0]", "ERROR", __FILE__, __LINE__); + } + + // Recherche si des utilisateurs ont lancé des lives depuis Pod + $cmdStatus0 = "curl --silent -H 'Content-Type: application/json' "; + $cmdStatus0 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmdStatus0 .= "-X GET " . checkEndWithoutSlash(POD_URL) . "/rest/bbb_livestream/?status=0"; + + $verificationStatus0 = exec("$cmdStatus0 2>&1", $aRetourVerificationStatus0, $sRetourVerificationStatus0); + + writeLog("Recherche si des utilisateurs ont lancé des lives depuis Pod", "DEBUG"); + + // En cas d'erreur, le code de retour est différent de 0 + if ($sRetourVerificationStatus0 == 0) { + writeLog(" + Commande '$cmdStatus0' : $aRetourVerificationStatus0[0]", "DEBUG"); + + $oListeSessions = json_decode($aRetourVerificationStatus0[0]); + // Recherche des nouvelles demandes de lives, sauvegardées dans Pod + for ($i = 0; $i < $oListeSessions->count; $i++) { + // Identifiant du live BBB dans Pod + $idLive = $oListeSessions->results[$i]->id; + // Adresse de la session dans Pod + $urlMeeting = $oListeSessions->results[$i]->meeting; + // Nom du serveur/processus déjà en charge de ce live + $server = $oListeSessions->results[$i]->server; + // Le live est il déjà en cours sur un des serveurs BBB-POD-LIVE ? + $status = $oListeSessions->results[$i]->status; + // Utilisateur ayant lancé ce live + $user = $oListeSessions->results[$i]->user; + // Identifiant du répertoire bbb-live-streaming qui s'occupera de réaliser le live, si disponible + $idBbbLiveStreaming = 0; + // Recherche si ce serveur peut encore lancer un direct + for ($j = 1; $j <= NUMBER_LIVES; $j++) { + // Variable de travail + $idBbbLiveStreamingUsed = false; + foreach ($GLOBALS["livesInProgressOnThisServer"] as $ligneLiveInProgressOnThisServer) { + // Cet idBbbLiveStreaming est déjà utilisé + if ($ligneLiveInProgressOnThisServer->idBbbLiveStreaming == $j) { + $idBbbLiveStreamingUsed = true; + } + } + // Le slot idBbbLiveStreaming est non utilisé + if (! $idBbbLiveStreamingUsed) { + // Un slot est disponible + $idBbbLiveStreaming = $j; + // Ajout de l'information aux lives en cours sur ce serveur + $liveInProgress2 = new stdClass(); + $liveInProgress2->id = $idLive; + $liveInProgress2->idBbbLiveStreaming = $idBbbLiveStreaming; + $GLOBALS["livesInProgressOnThisServer"][] = $liveInProgress2; + break; + } + } + // Un slot est disponible sur ce serveur pour réaliser un live ? + if ($idBbbLiveStreaming == 0) { + writeLog(" => Impossible de lancer le live $idLive de $user sur ce serveur : il y a déjà " . NUMBER_LIVES . " directs qui sont gérés par ce serveur.", "INFO"); + } else { + writeLog(" => Lancement du live $idLive de $user, via bbb-live-streaming$idBbbLiveStreaming", "INFO"); + configureAndStartLive($idLive, $urlMeeting, $idBbbLiveStreaming); + } + } + } else { + writeLog(" + Commande '$cmdStatus0' : $sRetourVerificationStatus0[0]", "ERROR", __FILE__, __LINE__); + } } /** @@ -255,216 +252,251 @@ function startLives() { * @param string $urlMeeting - URL de la session BBB de Pod à démarrer (cf. table bbb_livestream) * @param string $idBbbLiveStreaming - Identifiant du répertoire bbb-live-streaming qui va être utilisé pour lancer ce direct */ -function configureAndStartLive($idLive, $urlMeeting, $idBbbLiveStreaming) { - writeLog("-----Configuration et démarrage du direct : configureAndStartLive($idLive, '$urlMeeting', $idBbbLiveStreaming)-----", "DEBUG"); - - $cmd = "curl --silent -H 'Content-Type: application/json' "; - $cmd .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmd .= "-X GET $urlMeeting"; - $verification = exec("$cmd 2>&1", $aRetourVerification, $sRetourVerification); - - writeLog("Récupération de l'objet meeting ($urlMeeting) depuis Pod", "DEBUG"); - - if ($sRetourVerification == 0) { - writeLog(" + Commande '$cmd' : $aRetourVerification[0]", "DEBUG"); - - // Récupération de l'objet meeting - $oMeeting = json_decode($aRetourVerification[0]); - // Nom de la session, sans caractères problématiques ni espaces, et la chaîne bbb- en premier pour éviter toute confusion avec un diffuseur existant - $nameMeeting = "bbb-" . formatString($oMeeting->meeting_name); - // Nom de la session, sans caractères problématiques avec espaces, et la chaîne (BBB) en premier pour éviter toute confusion avec un diffuseur existant - $nameMeetingToDisplay = "[BBB] " . formatStringToDisplay($oMeeting->meeting_name); - // Id de la session - $idMeeting = $oMeeting->meeting_id; - - - // Récupération des informations concernant les options saisies par l'utilisateur - $cmdOptions = "curl --silent -H 'Content-Type: application/json' "; - $cmdOptions .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmdOptions .= "-X GET " . checkEndWithoutSlash(POD_URL) . "/rest/bbb_livestream/$idLive/"; - $verificationOptions = exec("$cmdOptions 2>&1", $aRetourVerificationOptions, $sRetourVerificationOptions); - - writeLog("Récupération des options de l'objet bbb_livestream (/rest/bbb_livestream/$idLive/) depuis Pod", "DEBUG"); - - $isRestricted = "false"; - $enableChat = "false"; - $showChat = "true"; - $downloadMeeting = "false"; - if ($sRetourVerificationOptions == 0) { - // Récupération de l'objet live - $oLive = json_decode($aRetourVerificationOptions[0]); - // Accès restreint - if ($oLive->is_restricted == 1) { - $isRestricted = "true"; - } else { - $isRestricted = "false"; - } - // Utilisation du chat - if ($oLive->enable_chat == 1) { - $enableChat = "true"; - } else { - $enableChat = "false"; - } - // Affichage du chat dans la vidéo - if ($oLive->show_chat == 1) { - $showChat = "true"; - } else { - $showChat = "false"; - } - // Téléchargement de la vidéo en fin de live - if ($oLive->download_meeting == 1) { - $downloadMeeting = "true"; - } else { - $downloadMeeting = "false"; - } - } - else { - writeLog(" + Commande '$cmdOptions' : $sRetourVerificationOptions[0]", "ERROR", __FILE__, __LINE__); - } - - /* Modification de la configuration du docker-compose.yml */ - writeLog(" + Modification de la configuration du docker-compose.yml", "DEBUG"); - $dockerFile = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $idBbbLiveStreaming . "/docker-compose.yml"; - // Configuration du nom du container (container_name) - $nameContainer = "liveStreaming" . $idBbbLiveStreaming; - $cmdSed0 = "sed -i \"s/^.*container_name:.*/ container_name: $nameContainer/\" $dockerFile"; - exec("$cmdSed0 2>&1", $aRetourVerificationSed0, $sRetourVerificationSed0); - if ($sRetourVerificationSed0 != 0) { writeLog(" - Commande '$cmdSed0' : $aRetourVerificationSed0[0]", "ERROR", __FILE__, __LINE__); } - // Configuration du port utilisé par le host (ligne sous ports:), de la forme - "6379:6379" pour 1°, - "6380:6379" pour le 2°.... - $port = 6378 + $idBbbLiveStreaming; - $cmdSed01 = "sed -i \"s/^.*:6379:.*/ - \"$port:6379\"/\" $dockerFile"; - exec("$cmdSed01 2>&1", $aRetourVerificationSed01, $sRetourVerificationSed01); - if ($sRetourVerificationSed01 != 0) { writeLog(" - Commande '$cmdSed01' : $aRetourVerificationSed01[0]", "ERROR", __FILE__, __LINE__); } - // Configuration du serveur BBB/Scalelite (BBB_URL) - // Gestion des caractères / de BBB_URL pour être utilisé via sed - $bbbURL = str_replace("/", "\/", checkEndWithoutSlash(BBB_URL)); - $cmdSed1 = "sed -i \"s/^.*BBB_URL=.*/ - BBB_URL=$bbbURL/\" $dockerFile"; - exec("$cmdSed1 2>&1", $aRetourVerificationSed1, $sRetourVerificationSed1); - if ($sRetourVerificationSed1 != 0) { writeLog(" - Commande '$cmdSed1' : $aRetourVerificationSed1[0]", "ERROR", __FILE__, __LINE__); } - // Configuration de la clé secrète (BBB_SECRET) - $cmdSed2 = "sed -i \"s/^.*BBB_SECRET=.*/ - BBB_SECRET=".BBB_SECRET."/\" $dockerFile"; - exec("$cmdSed2 2>&1", $aRetourVerificationSed2, $sRetourVerificationSed2); - if ($sRetourVerificationSed2 != 0) { writeLog(" - Commande '$cmdSed2' : $aRetourVerificationSed2[0]", "ERROR", __FILE__, __LINE__); } - // Configuration de la timezone (TZ) - $cmdSed3 = "sed -i \"s/^.*TZ=.*/ - TZ=Europe\/Paris/\" $dockerFile"; - exec("$cmdSed3 2>&1", $aRetourVerificationSed3, $sRetourVerificationSed3); - if ($sRetourVerificationSed3 != 0) { writeLog(" - Commande '$cmdSed3' : $aRetourVerificationSed3[0]", "ERROR", __FILE__, __LINE__); } - // Configuration de la résolution (BBB_RESOLUTION) - $cmdSed4 = "sed -i \"s/^.*BBB_RESOLUTION=.*/ - BBB_RESOLUTION=".BBB_RESOLUTION."/\" $dockerFile"; - exec("$cmdSed4 2>&1", $aRetourVerificationSed4, $sRetourVerificationSed4); - if ($sRetourVerificationSed4 != 0) { writeLog(" - Commande '$cmdSed4' : $aRetourVerificationSed4[0]", "ERROR", __FILE__, __LINE__); } - // Configuration du bitrate de la vidéo (FFMPEG_STREAM_VIDEO_BITRATE) - $cmdSed5 = "sed -i \"s/^.*FFMPEG_STREAM_VIDEO_BITRATE=.*/ - FFMPEG_STREAM_VIDEO_BITRATE=".FFMPEG_STREAM_VIDEO_BITRATE."/\" $dockerFile"; - exec("$cmdSed5 2>&1", $aRetourVerificationSed5, $sRetourVerificationSed5); - if ($sRetourVerificationSed5 != 0) { writeLog(" - Commande '$cmdSed5' : $aRetourVerificationSed5[0]", "ERROR", __FILE__, __LINE__); } - // Configuration du nombre de threads (FFMPEG_STREAM_THREADS) - $cmdSed6 = "sed -i \"s/^.*FFMPEG_STREAM_THREADS=.*/ - FFMPEG_STREAM_THREADS=".FFMPEG_STREAM_THREADS."/\" $dockerFile"; - exec("$cmdSed6 2>&1", $aRetourVerificationSed6, $sRetourVerificationSed6); - if ($sRetourVerificationSed6 != 0) { writeLog(" - Commande '$cmdSed6' : $aRetourVerificationSed6[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant l'id de la session à streamer (BBB_MEETING_ID) - $cmdSed7 = "sed -i \"s/^.*BBB_MEETING_ID=.*/ - BBB_MEETING_ID=$idMeeting/\" " . $dockerFile; - exec("$cmdSed7 2>&1", $aRetourVerificationSed7, $sRetourVerificationSed7); - if ($sRetourVerificationSed7 != 0) { writeLog(" - Commande '$cmdSed7' : $aRetourVerificationSed7[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant le flux RTMP (BBB_STREAM_URL) - // Gestion des caractères / du serveur RTMP pour être utilisé via sed - $rtmpServer = str_replace("/", "\/", checkEndSlash(BBB_STREAM_URL)); - $cmdSed8 = "sed -i \"s/^.*BBB_STREAM_URL=.*/ - BBB_STREAM_URL=" . $rtmpServer . "$nameMeeting/\" " . $dockerFile; - exec("$cmdSed8 2>&1", $aRetourVerificationSed8, $sRetourVerificationSed8); - if ($sRetourVerificationSed8 != 0) { writeLog(" - Commande '$cmdSed8' : $aRetourVerificationSed8[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant l'utilisation de chat (BBB_ENABLE_CHAT) - $cmdSed9 = "sed -i \"s/^.*BBB_ENABLE_CHAT=.*/ - BBB_ENABLE_CHAT=$enableChat/\" " . $dockerFile; - exec("$cmdSed9 2>&1", $aRetourVerificationSed9, $sRetourVerificationSed9); - if ($sRetourVerificationSed9 != 0) { writeLog(" - Commande '$cmdSed9' : $aRetourVerificationSed9[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant l'affichage du chat dans la vidéo (BBB_SHOW_CHAT) - $cmdSed10 = "sed -i \"s/^.*BBB_SHOW_CHAT=.*/ - BBB_SHOW_CHAT=$showChat/\" " . $dockerFile; - exec("$cmdSed10 2>&1", $aRetourVerificationSed10, $sRetourVerificationSed10); - if ($sRetourVerificationSed10 != 0) { writeLog(" - Commande '$cmdSed10' : $aRetourVerificationSed10[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant l'enregistrement de la vidéo du live (BBB_DOWNLOAD_MEETING) - $cmdSed11 = "sed -i \"s/^.*BBB_DOWNLOAD_MEETING=.*/ - BBB_DOWNLOAD_MEETING=$downloadMeeting/\" " . $dockerFile; - exec("$cmdSed11 2>&1", $aRetourVerificationSed11, $sRetourVerificationSed11); - if ($sRetourVerificationSed11 != 0) { writeLog(" - Commande '$cmdSed11' : $aRetourVerificationSed11[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant le titre de la session (BBB_MEETING_TITLE) - $cmdSed12 = "sed -i \"s/^.*BBB_MEETING_TITLE=.*/ - BBB_MEETING_TITLE=$nameMeetingToDisplay/\" " . $dockerFile; - exec("$cmdSed12 2>&1", $aRetourVerificationSed12, $sRetourVerificationSed12); - if ($sRetourVerificationSed12 != 0) { writeLog(" - Commande '$cmdSed12' : $aRetourVerificationSed12[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant le mot de passe d'un participant de la session (BBB_ATTENDEE_PASSWORD) - $cmdSed13 = "sed -i \"s/^.*BBB_ATTENDEE_PASSWORD=.*/ - BBB_ATTENDEE_PASSWORD=".BBB_ATTENDEE_PASSWORD."/\" " . $dockerFile; - exec("$cmdSed13 2>&1", $aRetourVerificationSed13, $sRetourVerificationSed13); - if ($sRetourVerificationSed13 != 0) { writeLog(" - Commande '$cmdSed13' : $aRetourVerificationSed13[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant le mot de passe d'un modérateur de la session (BBB_MODERATOR_PASSWORD) - $cmdSed14 = "sed -i \"s/^.*BBB_MODERATOR_PASSWORD=.*/ - BBB_MODERATOR_PASSWORD=".BBB_MODERATOR_PASSWORD."/\" " . $dockerFile; - exec("$cmdSed14 2>&1", $aRetourVerificationSed14, $sRetourVerificationSed14); - if ($sRetourVerificationSed14 != 0) { writeLog(" - Commande '$cmdSed14' : $aRetourVerificationSed14[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant le channel pour REDIS, encas d'utilisation du chat (BBB_REDIS_CHANNEL) - // Typiquement pour le répertoire 1 => chat1, 2 => chat2, 3 => chat3... - $channelRedis = "chat" . $idBbbLiveStreaming; - $cmdSed15 = "sed -i \"s/^.*BBB_REDIS_CHANNEL=.*/ - BBB_REDIS_CHANNEL=$channelRedis/\" " . $dockerFile; - exec("$cmdSed15 2>&1", $aRetourVerificationSed15, $sRetourVerificationSed15); - if ($sRetourVerificationSed15 != 0) { writeLog(" - Commande '$cmdSed15' : $aRetourVerificationSed15[0]", "ERROR", __FILE__, __LINE__); } - // Modification de la ligne concernant le mode DEBUG (DEBUG) - if (DEBUG) { $debug = "true"; } else { $debug = "false"; } - $cmdSed16 = "sed -i \"s/^.*DEBUG=.*/ - DEBUG=$debug/\" " . $dockerFile; - exec("$cmdSed16 2>&1", $aRetourVerificationSed16, $sRetourVerificationSed16); - if ($sRetourVerificationSed16 != 0) { writeLog(" - Commande '$cmdSed16' : $aRetourVerificationSed16[0]", "ERROR", __FILE__, __LINE__); } - - - /* Création du diffuseur correspondant dans Pod */ - $cmdBroadcaster = "curl --silent -H 'Content-Type: multipart/form-data' "; - $cmdBroadcaster .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmdBroadcaster .= "-F 'url=" . checkEndSlash(POD_HLS_STREAM) . "$nameMeeting.m3u8' "; - $cmdBroadcaster .= "-F 'building=" . checkEndWithoutSlash(POD_URL) . "/rest/buildings/" . POD_ID_BUILDING . "/' "; - $cmdBroadcaster .= "-F 'name=$nameMeetingToDisplay' -F 'status=true' -F 'is_restricted=$isRestricted' '" . checkEndWithoutSlash(POD_URL) . "/rest/broadcasters/'"; - $verificationBroadcaster = exec("$cmdBroadcaster 2>&1", $aRetourVerificationBroadcaster, $sRetourVerificationBroadcaster); - - writeLog(" + Création du diffuseur correspondant dans Pod", "DEBUG"); - - if ($sRetourVerificationBroadcaster == 0) { - writeLog(" - Commande '$cmdBroadcaster' : $aRetourVerificationBroadcaster[0]", "DEBUG"); - // Id du diffuseur - $idBroadcaster = 0; - - // Récupération du diffuseur créé - $oBroadcaster = json_decode($aRetourVerificationBroadcaster[0]); - - // Si le diffuseur existe déjà, $aRetourVerificationBroadcaster[0] contiendra un message d'avertissement du type : - // {"url":["Un objet Diffuseur avec ce champ URL existe déjà."],"name":["Un objet Diffuseur avec ce champ nom existe déjà."]} - if (strpos($aRetourVerificationBroadcaster[0], "Un objet Diffuseur avec ce champ nom existe déjà.") !== false) { - $cmdBroadcaster2 = "curl --silent -H 'Content-Type: application/json' "; - $cmdBroadcaster2 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmdBroadcaster2 .= "-X GET " . checkEndWithoutSlash(POD_URL) . "/rest/broadcasters/$nameMeeting/"; - $verificationBroadcaster2 = exec("$cmdBroadcaster2 2>&1", $aRetourVerificationBroadcaster2, $sRetourVerificationBroadcaster2); - - writeLog(" + Récupération du diffuseur déjà existant dans Pod", "DEBUG"); - if ($sRetourVerificationBroadcaster2 == 0) { - writeLog(" - Commande '$cmdBroadcaster2' : $aRetourVerificationBroadcaster2[0]", "DEBUG"); - $oBroadcaster2 = json_decode($aRetourVerificationBroadcaster2[0]); - $idBroadcaster = $oBroadcaster2->id; - } - else { - writeLog(" + Commande '$cmdBroadcaster2' : $aRetourVerificationBroadcaster2[0]", "ERROR", __FILE__, __LINE__); - } - } - else { - $idBroadcaster = $oBroadcaster->id; - } - - if ($idBroadcaster != 0) { - writeLog(" + Utilisation du diffuseur $idBroadcaster", "DEBUG"); - - // Démarrage du live, si nécessaire - startLive($idLive, checkEndSlash(BBB_STREAM_URL) . "$nameMeeting", $idBbbLiveStreaming, $idBroadcaster); - } - else { - writeLog(" + Démarrage impossible du live : aucun identifiant du diffuseur défini.", "ERROR", __FILE__, __LINE__); - } - } - else { - writeLog(" + Commande '$cmdBroadcaster' : $aRetourVerificationBroadcaster[0]", "ERROR", __FILE__, __LINE__); - } - } - else { - writeLog(" + Commande '$cmd' : $sRetourVerification[0]", "ERROR", __FILE__, __LINE__); - } +function configureAndStartLive($idLive, $urlMeeting, $idBbbLiveStreaming) +{ + writeLog("-----Configuration et démarrage du direct : configureAndStartLive($idLive, '$urlMeeting', $idBbbLiveStreaming)-----", "DEBUG"); + + $cmd = "curl --silent -H 'Content-Type: application/json' "; + $cmd .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmd .= "-X GET $urlMeeting"; + $verification = exec("$cmd 2>&1", $aRetourVerification, $sRetourVerification); + + writeLog("Récupération de l'objet meeting ($urlMeeting) depuis Pod", "DEBUG"); + + if ($sRetourVerification == 0) { + writeLog(" + Commande '$cmd' : $aRetourVerification[0]", "DEBUG"); + + // Récupération de l'objet meeting + $oMeeting = json_decode($aRetourVerification[0]); + // Nom de la session, sans caractères problématiques ni espaces, et la chaîne bbb- en premier pour éviter toute confusion avec un diffuseur existant + $nameMeeting = "bbb-" . formatString($oMeeting->meeting_name); + // Nom de la session, sans caractères problématiques avec espaces, et la chaîne (BBB) en premier pour éviter toute confusion avec un diffuseur existant + $nameMeetingToDisplay = "[BBB] " . formatStringToDisplay($oMeeting->meeting_name); + // Id de la session + $idMeeting = $oMeeting->meeting_id; + + + // Récupération des informations concernant les options saisies par l'utilisateur + $cmdOptions = "curl --silent -H 'Content-Type: application/json' "; + $cmdOptions .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmdOptions .= "-X GET " . checkEndWithoutSlash(POD_URL) . "/rest/bbb_livestream/$idLive/"; + $verificationOptions = exec("$cmdOptions 2>&1", $aRetourVerificationOptions, $sRetourVerificationOptions); + + writeLog("Récupération des options de l'objet bbb_livestream (/rest/bbb_livestream/$idLive/) depuis Pod", "DEBUG"); + + $isRestricted = "false"; + $enableChat = "false"; + $showChat = "true"; + $downloadMeeting = "false"; + if ($sRetourVerificationOptions == 0) { + // Récupération de l'objet live + $oLive = json_decode($aRetourVerificationOptions[0]); + // Accès restreint + if ($oLive->is_restricted == 1) { + $isRestricted = "true"; + } else { + $isRestricted = "false"; + } + // Utilisation du chat + if ($oLive->enable_chat == 1) { + $enableChat = "true"; + } else { + $enableChat = "false"; + } + // Affichage du chat dans la vidéo + if ($oLive->show_chat == 1) { + $showChat = "true"; + } else { + $showChat = "false"; + } + // Téléchargement de la vidéo en fin de live + if ($oLive->download_meeting == 1) { + $downloadMeeting = "true"; + } else { + $downloadMeeting = "false"; + } + } else { + writeLog(" + Commande '$cmdOptions' : $sRetourVerificationOptions[0]", "ERROR", __FILE__, __LINE__); + } + + /* Modification de la configuration du docker-compose.yml */ + writeLog(" + Modification de la configuration du docker-compose.yml", "DEBUG"); + $dockerFile = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $idBbbLiveStreaming . "/docker-compose.yml"; + // Configuration du nom du container (container_name) + $nameContainer = "liveStreaming" . $idBbbLiveStreaming; + $cmdSed0 = "sed -i \"s/^.*container_name:.*/ container_name: $nameContainer/\" $dockerFile"; + exec("$cmdSed0 2>&1", $aRetourVerificationSed0, $sRetourVerificationSed0); + if ($sRetourVerificationSed0 != 0) { + writeLog(" - Commande '$cmdSed0' : $aRetourVerificationSed0[0]", "ERROR", __FILE__, __LINE__); + } + // Configuration du port utilisé par le host (ligne sous ports:), de la forme - "6379:6379" pour 1°, - "6380:6379" pour le 2°.... + $port = 6378 + $idBbbLiveStreaming; + $cmdSed01 = "sed -i \"s/^.*:6379:.*/ - \"$port:6379\"/\" $dockerFile"; + exec("$cmdSed01 2>&1", $aRetourVerificationSed01, $sRetourVerificationSed01); + if ($sRetourVerificationSed01 != 0) { + writeLog(" - Commande '$cmdSed01' : $aRetourVerificationSed01[0]", "ERROR", __FILE__, __LINE__); + } + // Configuration du serveur BBB/Scalelite (BBB_URL) + // Gestion des caractères / de BBB_URL pour être utilisé via sed + $bbbURL = str_replace("/", "\/", checkEndWithoutSlash(BBB_URL)); + $cmdSed1 = "sed -i \"s/^.*BBB_URL=.*/ - BBB_URL=$bbbURL/\" $dockerFile"; + exec("$cmdSed1 2>&1", $aRetourVerificationSed1, $sRetourVerificationSed1); + if ($sRetourVerificationSed1 != 0) { + writeLog(" - Commande '$cmdSed1' : $aRetourVerificationSed1[0]", "ERROR", __FILE__, __LINE__); + } + // Configuration de la clé secrète (BBB_SECRET) + $cmdSed2 = "sed -i \"s/^.*BBB_SECRET=.*/ - BBB_SECRET=".BBB_SECRET."/\" $dockerFile"; + exec("$cmdSed2 2>&1", $aRetourVerificationSed2, $sRetourVerificationSed2); + if ($sRetourVerificationSed2 != 0) { + writeLog(" - Commande '$cmdSed2' : $aRetourVerificationSed2[0]", "ERROR", __FILE__, __LINE__); + } + // Configuration de la timezone (TZ) + $cmdSed3 = "sed -i \"s/^.*TZ=.*/ - TZ=Europe\/Paris/\" $dockerFile"; + exec("$cmdSed3 2>&1", $aRetourVerificationSed3, $sRetourVerificationSed3); + if ($sRetourVerificationSed3 != 0) { + writeLog(" - Commande '$cmdSed3' : $aRetourVerificationSed3[0]", "ERROR", __FILE__, __LINE__); + } + // Configuration de la résolution (BBB_RESOLUTION) + $cmdSed4 = "sed -i \"s/^.*BBB_RESOLUTION=.*/ - BBB_RESOLUTION=".BBB_RESOLUTION."/\" $dockerFile"; + exec("$cmdSed4 2>&1", $aRetourVerificationSed4, $sRetourVerificationSed4); + if ($sRetourVerificationSed4 != 0) { + writeLog(" - Commande '$cmdSed4' : $aRetourVerificationSed4[0]", "ERROR", __FILE__, __LINE__); + } + // Configuration du bitrate de la vidéo (FFMPEG_STREAM_VIDEO_BITRATE) + $cmdSed5 = "sed -i \"s/^.*FFMPEG_STREAM_VIDEO_BITRATE=.*/ - FFMPEG_STREAM_VIDEO_BITRATE=".FFMPEG_STREAM_VIDEO_BITRATE."/\" $dockerFile"; + exec("$cmdSed5 2>&1", $aRetourVerificationSed5, $sRetourVerificationSed5); + if ($sRetourVerificationSed5 != 0) { + writeLog(" - Commande '$cmdSed5' : $aRetourVerificationSed5[0]", "ERROR", __FILE__, __LINE__); + } + // Configuration du nombre de threads (FFMPEG_STREAM_THREADS) + $cmdSed6 = "sed -i \"s/^.*FFMPEG_STREAM_THREADS=.*/ - FFMPEG_STREAM_THREADS=".FFMPEG_STREAM_THREADS."/\" $dockerFile"; + exec("$cmdSed6 2>&1", $aRetourVerificationSed6, $sRetourVerificationSed6); + if ($sRetourVerificationSed6 != 0) { + writeLog(" - Commande '$cmdSed6' : $aRetourVerificationSed6[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant l'id de la session à streamer (BBB_MEETING_ID) + $cmdSed7 = "sed -i \"s/^.*BBB_MEETING_ID=.*/ - BBB_MEETING_ID=$idMeeting/\" " . $dockerFile; + exec("$cmdSed7 2>&1", $aRetourVerificationSed7, $sRetourVerificationSed7); + if ($sRetourVerificationSed7 != 0) { + writeLog(" - Commande '$cmdSed7' : $aRetourVerificationSed7[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant le flux RTMP (BBB_STREAM_URL) + // Gestion des caractères / du serveur RTMP pour être utilisé via sed + $rtmpServer = str_replace("/", "\/", checkEndSlash(BBB_STREAM_URL)); + $cmdSed8 = "sed -i \"s/^.*BBB_STREAM_URL=.*/ - BBB_STREAM_URL=" . $rtmpServer . "$nameMeeting/\" " . $dockerFile; + exec("$cmdSed8 2>&1", $aRetourVerificationSed8, $sRetourVerificationSed8); + if ($sRetourVerificationSed8 != 0) { + writeLog(" - Commande '$cmdSed8' : $aRetourVerificationSed8[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant l'utilisation de chat (BBB_ENABLE_CHAT) + $cmdSed9 = "sed -i \"s/^.*BBB_ENABLE_CHAT=.*/ - BBB_ENABLE_CHAT=$enableChat/\" " . $dockerFile; + exec("$cmdSed9 2>&1", $aRetourVerificationSed9, $sRetourVerificationSed9); + if ($sRetourVerificationSed9 != 0) { + writeLog(" - Commande '$cmdSed9' : $aRetourVerificationSed9[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant l'affichage du chat dans la vidéo (BBB_SHOW_CHAT) + $cmdSed10 = "sed -i \"s/^.*BBB_SHOW_CHAT=.*/ - BBB_SHOW_CHAT=$showChat/\" " . $dockerFile; + exec("$cmdSed10 2>&1", $aRetourVerificationSed10, $sRetourVerificationSed10); + if ($sRetourVerificationSed10 != 0) { + writeLog(" - Commande '$cmdSed10' : $aRetourVerificationSed10[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant l'enregistrement de la vidéo du live (BBB_DOWNLOAD_MEETING) + $cmdSed11 = "sed -i \"s/^.*BBB_DOWNLOAD_MEETING=.*/ - BBB_DOWNLOAD_MEETING=$downloadMeeting/\" " . $dockerFile; + exec("$cmdSed11 2>&1", $aRetourVerificationSed11, $sRetourVerificationSed11); + if ($sRetourVerificationSed11 != 0) { + writeLog(" - Commande '$cmdSed11' : $aRetourVerificationSed11[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant le titre de la session (BBB_MEETING_TITLE) + $cmdSed12 = "sed -i \"s/^.*BBB_MEETING_TITLE=.*/ - BBB_MEETING_TITLE=$nameMeetingToDisplay/\" " . $dockerFile; + exec("$cmdSed12 2>&1", $aRetourVerificationSed12, $sRetourVerificationSed12); + if ($sRetourVerificationSed12 != 0) { + writeLog(" - Commande '$cmdSed12' : $aRetourVerificationSed12[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant le mot de passe d'un participant de la session (BBB_ATTENDEE_PASSWORD) + $cmdSed13 = "sed -i \"s/^.*BBB_ATTENDEE_PASSWORD=.*/ - BBB_ATTENDEE_PASSWORD=".BBB_ATTENDEE_PASSWORD."/\" " . $dockerFile; + exec("$cmdSed13 2>&1", $aRetourVerificationSed13, $sRetourVerificationSed13); + if ($sRetourVerificationSed13 != 0) { + writeLog(" - Commande '$cmdSed13' : $aRetourVerificationSed13[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant le mot de passe d'un modérateur de la session (BBB_MODERATOR_PASSWORD) + $cmdSed14 = "sed -i \"s/^.*BBB_MODERATOR_PASSWORD=.*/ - BBB_MODERATOR_PASSWORD=".BBB_MODERATOR_PASSWORD."/\" " . $dockerFile; + exec("$cmdSed14 2>&1", $aRetourVerificationSed14, $sRetourVerificationSed14); + if ($sRetourVerificationSed14 != 0) { + writeLog(" - Commande '$cmdSed14' : $aRetourVerificationSed14[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant le channel pour REDIS, encas d'utilisation du chat (BBB_REDIS_CHANNEL) + // Typiquement pour le répertoire 1 => chat1, 2 => chat2, 3 => chat3... + $channelRedis = "chat" . $idBbbLiveStreaming; + $cmdSed15 = "sed -i \"s/^.*BBB_REDIS_CHANNEL=.*/ - BBB_REDIS_CHANNEL=$channelRedis/\" " . $dockerFile; + exec("$cmdSed15 2>&1", $aRetourVerificationSed15, $sRetourVerificationSed15); + if ($sRetourVerificationSed15 != 0) { + writeLog(" - Commande '$cmdSed15' : $aRetourVerificationSed15[0]", "ERROR", __FILE__, __LINE__); + } + // Modification de la ligne concernant le mode DEBUG (DEBUG) + if (DEBUG) { + $debug = "true"; + } else { + $debug = "false"; + } + $cmdSed16 = "sed -i \"s/^.*DEBUG=.*/ - DEBUG=$debug/\" " . $dockerFile; + exec("$cmdSed16 2>&1", $aRetourVerificationSed16, $sRetourVerificationSed16); + if ($sRetourVerificationSed16 != 0) { + writeLog(" - Commande '$cmdSed16' : $aRetourVerificationSed16[0]", "ERROR", __FILE__, __LINE__); + } + + + /* Création du diffuseur correspondant dans Pod */ + $cmdBroadcaster = "curl --silent -H 'Content-Type: multipart/form-data' "; + $cmdBroadcaster .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmdBroadcaster .= "-F 'url=" . checkEndSlash(POD_HLS_STREAM) . "$nameMeeting.m3u8' "; + $cmdBroadcaster .= "-F 'building=" . checkEndWithoutSlash(POD_URL) . "/rest/buildings/" . POD_ID_BUILDING . "/' "; + $cmdBroadcaster .= "-F 'name=$nameMeetingToDisplay' -F 'status=true' -F 'is_restricted=$isRestricted' '" . checkEndWithoutSlash(POD_URL) . "/rest/broadcasters/'"; + $verificationBroadcaster = exec("$cmdBroadcaster 2>&1", $aRetourVerificationBroadcaster, $sRetourVerificationBroadcaster); + + writeLog(" + Création du diffuseur correspondant dans Pod", "DEBUG"); + + if ($sRetourVerificationBroadcaster == 0) { + writeLog(" - Commande '$cmdBroadcaster' : $aRetourVerificationBroadcaster[0]", "DEBUG"); + // Id du diffuseur + $idBroadcaster = 0; + + // Récupération du diffuseur créé + $oBroadcaster = json_decode($aRetourVerificationBroadcaster[0]); + + // Si le diffuseur existe déjà, $aRetourVerificationBroadcaster[0] contiendra un message d'avertissement du type : + // {"url":["Un objet Diffuseur avec ce champ URL existe déjà."],"name":["Un objet Diffuseur avec ce champ nom existe déjà."]} + if (strpos($aRetourVerificationBroadcaster[0], "Un objet Diffuseur avec ce champ nom existe déjà.") !== false) { + $cmdBroadcaster2 = "curl --silent -H 'Content-Type: application/json' "; + $cmdBroadcaster2 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmdBroadcaster2 .= "-X GET " . checkEndWithoutSlash(POD_URL) . "/rest/broadcasters/$nameMeeting/"; + $verificationBroadcaster2 = exec("$cmdBroadcaster2 2>&1", $aRetourVerificationBroadcaster2, $sRetourVerificationBroadcaster2); + + writeLog(" + Récupération du diffuseur déjà existant dans Pod", "DEBUG"); + if ($sRetourVerificationBroadcaster2 == 0) { + writeLog(" - Commande '$cmdBroadcaster2' : $aRetourVerificationBroadcaster2[0]", "DEBUG"); + $oBroadcaster2 = json_decode($aRetourVerificationBroadcaster2[0]); + $idBroadcaster = $oBroadcaster2->id; + } else { + writeLog(" + Commande '$cmdBroadcaster2' : $aRetourVerificationBroadcaster2[0]", "ERROR", __FILE__, __LINE__); + } + } else { + $idBroadcaster = $oBroadcaster->id; + } + + if ($idBroadcaster != 0) { + writeLog(" + Utilisation du diffuseur $idBroadcaster", "DEBUG"); + + // Démarrage du live, si nécessaire + startLive($idLive, checkEndSlash(BBB_STREAM_URL) . "$nameMeeting", $idBbbLiveStreaming, $idBroadcaster); + } else { + writeLog(" + Démarrage impossible du live : aucun identifiant du diffuseur défini.", "ERROR", __FILE__, __LINE__); + } + } else { + writeLog(" + Commande '$cmdBroadcaster' : $aRetourVerificationBroadcaster[0]", "ERROR", __FILE__, __LINE__); + } + } else { + writeLog(" + Commande '$cmd' : $sRetourVerification[0]", "ERROR", __FILE__, __LINE__); + } } /** @@ -474,228 +506,220 @@ function configureAndStartLive($idLive, $urlMeeting, $idBbbLiveStreaming) { * @param string $idBbbLiveStreaming - Identifiant du répertoire bbb-live-streaming qui va être utilisé pour lancer ce direct * @param string $idBroadcaster - Identifiant du diffuseur qui va être utilisé pour lancer ce direct */ -function startLive($idLive, $streamName, $idBbbLiveStreaming, $idBroadcaster) { - writeLog("-----Démarrage du direct : startLive($idLive, '$streamName', $idBbbLiveStreaming, $idBroadcaster)-----", "DEBUG"); - - if (DEBUG) { - // Avec gestions des logs, dans le répertoire des logs. Le nom du fichier correspond à l'id du live BBB de Pod ((cf. table bbb_meeting) - $cmd = "cd " . checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $idBbbLiveStreaming . " ; docker-compose up 1>" . checkEndSlash(PHYSICAL_LOG_ROOT) . "$idLive.log"; - exec("$cmd 2>&1 &", $aRetourVerification, $sRetourVerification); - } - else { - // En mode daemon - $cmd = "cd " . checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $idBbbLiveStreaming . " ; docker-compose up -d"; - exec("$cmd 2>&1", $aRetourVerification, $sRetourVerification); - } - - writeLog("Démarrage du live", "DEBUG"); - - if ($sRetourVerification == 0) { - writeLog(" + Commande '$cmd'", "DEBUG"); - - // Définition du port pour REDIS (en cas d'utilisation du chat) - // Typiquement pour le répertoire 1 => 6379, 2 => 6380, 3 => 6381... - $portRedis = 6378 + $idBbbLiveStreaming; - - // Définition du channel pour REDIS (en cas d'utilisation du chat) - // Typiquement pour le répertoire 1 => chat1, 2 => chat2, 3 => chat3... - $channelRedis = "chat" . $idBbbLiveStreaming; - - // Mise à jour de l'information dans Pod, via l'API Rest - $cmdMajPod = "curl --silent -H 'Content-Type: application/json' "; - $cmdMajPod .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmdMajPod .= "-X PATCH -d '{\"server\":\"" . SERVER_NUMBER . "/" . $idBbbLiveStreaming . "\", \"status\":1, \"broadcaster_id\": $idBroadcaster, \"redis_hostname\":\"" . SERVER_HOSTNAME . "\", \"redis_port\": $portRedis, \"redis_channel\":\"$channelRedis\"}' "; - $cmdMajPod .= "" . checkEndWithoutSlash(POD_URL) . "/rest/bbb_livestream/$idLive/"; - exec("$cmdMajPod", $aRetourVerificationMajPod, $sRetourVerificationMajPod); - - writeLog(" + Mise à jour de l'information du bbb_livestream dans Pod", "DEBUG"); - - if ($sRetourVerificationMajPod == 0) { - writeLog(" - Commande '$cmdMajPod' : $aRetourVerificationMajPod[0]", "DEBUG"); - } - else { - writeLog(" - Commande '$cmdMajPod' : $sRetourVerificationMajPod[0]", "ERROR", __FILE__, __LINE__); - } - } - else { - writeLog(" + Commande '$cmd' : $sRetourVerification[0]", "ERROR", __FILE__, __LINE__); - } - sendEmail("[BBB-POD-LIVE] Démarrage d'un live", "Démarrage d'un direct (id : $idLive, stream : $streamName) sur le serveur " . SERVER_NUMBER); +function startLive($idLive, $streamName, $idBbbLiveStreaming, $idBroadcaster) +{ + writeLog("-----Démarrage du direct : startLive($idLive, '$streamName', $idBbbLiveStreaming, $idBroadcaster)-----", "DEBUG"); + + if (DEBUG) { + // Avec gestions des logs, dans le répertoire des logs. Le nom du fichier correspond à l'id du live BBB de Pod ((cf. table bbb_meeting) + $cmd = "cd " . checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $idBbbLiveStreaming . " ; docker compose up 1>" . checkEndSlash(PHYSICAL_LOG_ROOT) . "$idLive.log"; + exec("$cmd 2>&1 &", $aRetourVerification, $sRetourVerification); + } else { + // En mode daemon + $cmd = "cd " . checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $idBbbLiveStreaming . " ; docker compose up -d"; + exec("$cmd 2>&1", $aRetourVerification, $sRetourVerification); + } + + writeLog("Démarrage du live", "DEBUG"); + + if ($sRetourVerification == 0) { + writeLog(" + Commande '$cmd'", "DEBUG"); + + // Définition du port pour REDIS (en cas d'utilisation du chat) + // Typiquement pour le répertoire 1 => 6379, 2 => 6380, 3 => 6381... + $portRedis = 6378 + $idBbbLiveStreaming; + + // Définition du channel pour REDIS (en cas d'utilisation du chat) + // Typiquement pour le répertoire 1 => chat1, 2 => chat2, 3 => chat3... + $channelRedis = "chat" . $idBbbLiveStreaming; + + // Mise à jour de l'information dans Pod, via l'API Rest + $cmdMajPod = "curl --silent -H 'Content-Type: application/json' "; + $cmdMajPod .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmdMajPod .= "-X PATCH -d '{\"server\":\"" . SERVER_NUMBER . "/" . $idBbbLiveStreaming . "\", \"status\":1, \"broadcaster_id\": $idBroadcaster, \"redis_hostname\":\"" . SERVER_HOSTNAME . "\", \"redis_port\": $portRedis, \"redis_channel\":\"$channelRedis\"}' "; + $cmdMajPod .= "" . checkEndWithoutSlash(POD_URL) . "/rest/bbb_livestream/$idLive/"; + exec("$cmdMajPod", $aRetourVerificationMajPod, $sRetourVerificationMajPod); + + writeLog(" + Mise à jour de l'information du bbb_livestream dans Pod", "DEBUG"); + + if ($sRetourVerificationMajPod == 0) { + writeLog(" - Commande '$cmdMajPod' : $aRetourVerificationMajPod[0]", "DEBUG"); + } else { + writeLog(" - Commande '$cmdMajPod' : $sRetourVerificationMajPod[0]", "ERROR", __FILE__, __LINE__); + } + } else { + writeLog(" + Commande '$cmd' : $sRetourVerification[0]", "ERROR", __FILE__, __LINE__); + } + sendEmail("[BBB-POD-LIVE] Démarrage d'un live", "Démarrage d'un direct (id : $idLive, stream : $streamName) sur le serveur " . SERVER_NUMBER); } /** * Procédure permettant d'identifier et d'arrêter des directs dont la session BigBlueButton a été arrêtée. */ -function stopLives() { - writeLog("-----Arrêt des directs : stopLives()-----", "DEBUG"); - - // Checksum utile pour récupérer les informations des sessions en cours sur BigBlueButton/Scalelite - $checksum = sha1("getMeetings" . BBB_SECRET); - // Adresse utile pour récupérer les informations des sessions en cours sur BigBlueButton/Scalelite - $bbbUrlGetMeetings = checkEndWithoutSlash(BBB_URL) . "/getMeetings?checksum=" . $checksum; - // Variable permettant de connaitre les sessions en cours sur le serveur BigBlueButton/Scalelite - $meetingsInProgressOnBBB = array(); - - // On ne récupère les sessions du serveur BigBlueButton/Scalelite que s'il existe des lives en cours sur ce serveur - if (count($GLOBALS["livesInProgressOnThisServer"]) > 0) { - $xml = simplexml_load_file($bbbUrlGetMeetings); - writeLog("Récupération des sessions depuis le serveur BigBlueButton/Scalelite", "DEBUG"); - if($xml === FALSE) { - writeLog(" + Impossible de se connecter au serveur BBB/Scalelite : $bbbUrlGetMeetings", "ERROR", __FILE__, __LINE__); - } else { - writeLog(" + Requête sur le serveur BBB/Scalelite : $bbbUrlGetMeetings", "DEBUG"); - foreach ($xml->meetings->meeting as $meeting) { - // Ajout du meetingID au tableau des sessions BBB en cours - $meetingsInProgressOnBBB[] = $meeting->meetingID; - } - } - - // Recherche de tous les directs marqués comme étant en cours - foreach ($GLOBALS["livesInProgressOnThisServer"] as $ligneLiveInProgressOnThisServer) { - // Récupération du BBB_MEETING_ID correspondant dans le docker-compose.yml - $dockerFile = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $ligneLiveInProgressOnThisServer->idBbbLiveStreaming . "/docker-compose.yml"; - $dockerDirectory = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $ligneLiveInProgressOnThisServer->idBbbLiveStreaming; - $cmdGrep1="grep BBB_MEETING_ID $dockerFile| cut -d\"=\" -f2"; - $verificationGrep1 = exec("$cmdGrep1 2>&1", $aRetourVerificationGrep1, $sRetourVerificationGrep1); - if ($sRetourVerificationGrep1 == 0) { - writeLog(" + Commande '$cmdGrep1' : $aRetourVerificationGrep1[0]", "DEBUG"); - // Meeting ID correspondant - $bbbMeetingId = $aRetourVerificationGrep1[0]; - - // Recherche du nom du diffuseur correspondant (sauvegardé aussi dans BBB_MEETING_TITLE du fichier compose) - $broadcasterName = ""; - $cmdGrep2="grep BBB_MEETING_TITLE $dockerFile| cut -d\"=\" -f2"; - $verificationGrep2 = exec("$cmdGrep2 2>&1", $aRetourVerificationGrep2, $sRetourVerificationGrep2); - if ($sRetourVerificationGrep2 == 0) { - writeLog(" + Commande '$cmdGrep2' : $aRetourVerificationGrep2[0]", "DEBUG"); - // Nom du diffuseur correspondant - $broadcasterName = formatString($aRetourVerificationGrep2[0]); - } - else { - writeLog(" + Commande '$cmdGrep2' : $sRetourVerificationGrep2[0]", "ERROR", __FILE__, __LINE__); - } - - // Cet ID n'est plus dans la liste des sessions en cours sur BBB : - // - on arrête le container docker correspondant - // - on supprime le diffuseur correspondant - // - on copie, pour permettre l'encodage, le fichier vidéo si l'utilisateur a enregistré la session - if (! in_array($bbbMeetingId, $meetingsInProgressOnBBB)) { - writeLog(" + La session BigBlueButton $bbbMeetingId est arrêtée. Arrêt du container docker $dockerFile, suppression du diffuseur correspondant, copie du fichier vidéo généré selon le souhait de l'utilisateur", "INFO"); - $cmdStop = "cd $dockerDirectory; docker-compose down"; - exec("$cmdStop 2>&1", $aRetourVerificationStop, $sRetourVerificationStop); - if ($sRetourVerificationStop == 0) { - writeLog(" - Le container docker $dockerDirectory est bien arrêté", "DEBUG"); - // Formatage de la date d'arrêt dans le bon format - $endDate = date('Y-m-d H:i:s'); - // On sauvegarde cette information dans la base de Pod via l'appel à l'API Rest - $cmdMajPod1 = "curl --silent -H 'Content-Type: application/json' "; - $cmdMajPod1 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmdMajPod1 .= "-X PATCH -d '{\"end_date\":\"$endDate\", \"status\":2}' "; - $cmdMajPod1 .= "" . checkEndWithoutSlash(POD_URL) . "/rest/bbb_livestream/" . $ligneLiveInProgressOnThisServer->id . "/"; - exec("$cmdMajPod1", $aRetourVerificationMajPod1, $sRetourVerificationMajPod1); - - writeLog(" + Mise à jour de l'information du bbb_livestream dans Pod", "DEBUG"); - - // URL de l'API Rest du meeting en cours d'arrêt - $urlApiRestMeeting = ""; - // URL de l'API Rest du user qui a réalisé le live qui en cours d'arrêt - $urlApiRestUser = ""; - if ($sRetourVerificationMajPod1 == 0) { - writeLog(" - Commande '$cmdMajPod1' : $aRetourVerificationMajPod1[0]", "DEBUG"); - $oLive = json_decode($aRetourVerificationMajPod1[0]); - if (isset($oLive->meeting)) { - $urlApiRestMeeting = $oLive->meeting; - $urlApiRestUser = $oLive->user; - } - } - else { - writeLog(" - Commande '$cmdMajPod1' : $sRetourVerificationMajPod1[0]", "ERROR", __FILE__, __LINE__); - } - // Suppression du diffuseur - if ($broadcasterName != "") { - deleteBroadcaster($broadcasterName); - } - - // Recherche si l'utilisateur a souhaité cet enregistrement (sauvegardé aussi dans BBB_DOWNLOAD_MEETING du fichier compose) - $downloadMeeting = false; - $cmdGrep3="grep BBB_DOWNLOAD_MEETING $dockerFile| cut -d\"=\" -f2"; - $verificationGrep3 = exec("$cmdGrep3 2>&1", $aRetourVerificationGrep3, $sRetourVerificationGrep3); - if ($sRetourVerificationGrep3 == 0) { - writeLog(" + Commande '$cmdGrep3' : $aRetourVerificationGrep3[0]", "DEBUG"); - // Nom du diffuseur correspondant - if ($aRetourVerificationGrep3[0] == "true") { - $downloadMeeting = true; - } - } - else { - writeLog(" + Commande '$cmdGrep3' : $sRetourVerificationGrep3[0]", "ERROR", __FILE__, __LINE__); - } - - // Copie du fichier vidéo créé : si c'est configuré pour et que l'utilisateur a souhaité cet enregistrement - if (POD_DEFAULT_BBB_PATH != "" && $downloadMeeting) { - // Recherche de internal_meeting_id correspondant à cette session - $cmdMajPod2 = "curl --silent -H 'Content-Type: application/json' "; - $cmdMajPod2 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmdMajPod2 .= "-X PATCH -d '{\"encoded_by\":\"$urlApiRestUser\", \"encoding_step\":3}' "; - $cmdMajPod2 .= "$urlApiRestMeeting"; - $verificationMajPod2 = exec("$cmdMajPod2 2>&1", $aRetourVerificationMajPod2, $sRetourVerificationMajPod2); - - writeLog(" + Récupération de l'internal_meeting_id correspondant à l'objet bbb_meeting $bbbMeetingId depuis Pod", "DEBUG"); - $internalMeetingId = ""; - if ($sRetourVerificationMajPod2 == 0) { - writeLog(" - Commande '$cmdMajPod2' : $aRetourVerificationMajPod2[0]", "DEBUG"); - - // Recherche de l'internal_meeting_id correspondant au meeting - $oMeeting = json_decode($aRetourVerificationMajPod2[0]); - if (isset($oMeeting->internal_meeting_id)) { - $internalMeetingId = $oMeeting->internal_meeting_id; - } - } - else { - writeLog(" - Commande '$cmdMajPod2' : $sRetourVerificationMajPod2[0]", "ERROR", __FILE__, __LINE__); - } - - if ($internalMeetingId != "") { - processDirectory($ligneLiveInProgressOnThisServer->idBbbLiveStreaming, $internalMeetingId); - } - else { - writeLog(" - Impossible de récupérer l'internal meeting id pour le direct " . $ligneLiveInProgressOnThisServer->idBbbLiveStreaming, "ERROR", __FILE__, __LINE__); - } - } - } - else { - writeLog(" + Commande '$cmdStop' : $sRetourVerificationStop[0]", "ERROR", __FILE__, __LINE__); - } - } - } - else { - writeLog(" + Commande '$cmdGrep1' : $sRetourVerificationGrep1[0]", "ERROR", __FILE__, __LINE__); - } - } - } +function stopLives() +{ + writeLog("-----Arrêt des directs : stopLives()-----", "DEBUG"); + + // Checksum utile pour récupérer les informations des sessions en cours sur BigBlueButton/Scalelite + $checksum = sha1("getMeetings" . BBB_SECRET); + // Adresse utile pour récupérer les informations des sessions en cours sur BigBlueButton/Scalelite + $bbbUrlGetMeetings = checkEndWithoutSlash(BBB_URL) . "/getMeetings?checksum=" . $checksum; + // Variable permettant de connaitre les sessions en cours sur le serveur BigBlueButton/Scalelite + $meetingsInProgressOnBBB = array(); + + // On ne récupère les sessions du serveur BigBlueButton/Scalelite que s'il existe des lives en cours sur ce serveur + if (count($GLOBALS["livesInProgressOnThisServer"]) > 0) { + $xml = simplexml_load_file($bbbUrlGetMeetings); + writeLog("Récupération des sessions depuis le serveur BigBlueButton/Scalelite", "DEBUG"); + if ($xml === false) { + writeLog(" + Impossible de se connecter au serveur BBB/Scalelite : $bbbUrlGetMeetings", "ERROR", __FILE__, __LINE__); + } else { + writeLog(" + Requête sur le serveur BBB/Scalelite : $bbbUrlGetMeetings", "DEBUG"); + foreach ($xml->meetings->meeting as $meeting) { + // Ajout du meetingID au tableau des sessions BBB en cours + $meetingsInProgressOnBBB[] = $meeting->meetingID; + } + } + + // Recherche de tous les directs marqués comme étant en cours + foreach ($GLOBALS["livesInProgressOnThisServer"] as $ligneLiveInProgressOnThisServer) { + // Récupération du BBB_MEETING_ID correspondant dans le docker-compose.yml + $dockerFile = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $ligneLiveInProgressOnThisServer->idBbbLiveStreaming . "/docker-compose.yml"; + $dockerDirectory = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming" . $ligneLiveInProgressOnThisServer->idBbbLiveStreaming; + $cmdGrep1="grep BBB_MEETING_ID $dockerFile| cut -d\"=\" -f2"; + $verificationGrep1 = exec("$cmdGrep1 2>&1", $aRetourVerificationGrep1, $sRetourVerificationGrep1); + if ($sRetourVerificationGrep1 == 0) { + writeLog(" + Commande '$cmdGrep1' : $aRetourVerificationGrep1[0]", "DEBUG"); + // Meeting ID correspondant + $bbbMeetingId = $aRetourVerificationGrep1[0]; + + // Recherche du nom du diffuseur correspondant (sauvegardé aussi dans BBB_MEETING_TITLE du fichier compose) + $broadcasterName = ""; + $cmdGrep2="grep BBB_MEETING_TITLE $dockerFile| cut -d\"=\" -f2"; + $verificationGrep2 = exec("$cmdGrep2 2>&1", $aRetourVerificationGrep2, $sRetourVerificationGrep2); + if ($sRetourVerificationGrep2 == 0) { + writeLog(" + Commande '$cmdGrep2' : $aRetourVerificationGrep2[0]", "DEBUG"); + // Nom du diffuseur correspondant + $broadcasterName = formatString($aRetourVerificationGrep2[0]); + } else { + writeLog(" + Commande '$cmdGrep2' : $sRetourVerificationGrep2[0]", "ERROR", __FILE__, __LINE__); + } + + // Cet ID n'est plus dans la liste des sessions en cours sur BBB : + // - on arrête le container docker correspondant + // - on supprime le diffuseur correspondant + // - on copie, pour permettre l'encodage, le fichier vidéo si l'utilisateur a enregistré la session + if (! in_array($bbbMeetingId, $meetingsInProgressOnBBB)) { + writeLog(" + La session BigBlueButton $bbbMeetingId est arrêtée. Arrêt du container docker $dockerFile, suppression du diffuseur correspondant, copie du fichier vidéo généré selon le souhait de l'utilisateur", "INFO"); + $cmdStop = "cd $dockerDirectory; docker compose down"; + exec("$cmdStop 2>&1", $aRetourVerificationStop, $sRetourVerificationStop); + if ($sRetourVerificationStop == 0) { + writeLog(" - Le container docker $dockerDirectory est bien arrêté", "DEBUG"); + // Formatage de la date d'arrêt dans le bon format + $endDate = date('Y-m-d H:i:s'); + // On sauvegarde cette information dans la base de Pod via l'appel à l'API Rest + $cmdMajPod1 = "curl --silent -H 'Content-Type: application/json' "; + $cmdMajPod1 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmdMajPod1 .= "-X PATCH -d '{\"end_date\":\"$endDate\", \"status\":2}' "; + $cmdMajPod1 .= "" . checkEndWithoutSlash(POD_URL) . "/rest/bbb_livestream/" . $ligneLiveInProgressOnThisServer->id . "/"; + exec("$cmdMajPod1", $aRetourVerificationMajPod1, $sRetourVerificationMajPod1); + + writeLog(" + Mise à jour de l'information du bbb_livestream dans Pod", "DEBUG"); + + // URL de l'API Rest du meeting en cours d'arrêt + $urlApiRestMeeting = ""; + // URL de l'API Rest du user qui a réalisé le live qui en cours d'arrêt + $urlApiRestUser = ""; + if ($sRetourVerificationMajPod1 == 0) { + writeLog(" - Commande '$cmdMajPod1' : $aRetourVerificationMajPod1[0]", "DEBUG"); + $oLive = json_decode($aRetourVerificationMajPod1[0]); + if (isset($oLive->meeting)) { + $urlApiRestMeeting = $oLive->meeting; + $urlApiRestUser = $oLive->user; + } + } else { + writeLog(" - Commande '$cmdMajPod1' : $sRetourVerificationMajPod1[0]", "ERROR", __FILE__, __LINE__); + } + // Suppression du diffuseur + if ($broadcasterName != "") { + deleteBroadcaster($broadcasterName); + } + + // Recherche si l'utilisateur a souhaité cet enregistrement (sauvegardé aussi dans BBB_DOWNLOAD_MEETING du fichier compose) + $downloadMeeting = false; + $cmdGrep3="grep BBB_DOWNLOAD_MEETING $dockerFile| cut -d\"=\" -f2"; + $verificationGrep3 = exec("$cmdGrep3 2>&1", $aRetourVerificationGrep3, $sRetourVerificationGrep3); + if ($sRetourVerificationGrep3 == 0) { + writeLog(" + Commande '$cmdGrep3' : $aRetourVerificationGrep3[0]", "DEBUG"); + // Nom du diffuseur correspondant + if ($aRetourVerificationGrep3[0] == "true") { + $downloadMeeting = true; + } + } else { + writeLog(" + Commande '$cmdGrep3' : $sRetourVerificationGrep3[0]", "ERROR", __FILE__, __LINE__); + } + + // Copie du fichier vidéo créé : si c'est configuré pour et que l'utilisateur a souhaité cet enregistrement + if (POD_DEFAULT_BBB_PATH != "" && $downloadMeeting) { + // Recherche de internal_meeting_id correspondant à cette session + $cmdMajPod2 = "curl --silent -H 'Content-Type: application/json' "; + $cmdMajPod2 .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmdMajPod2 .= "-X PATCH -d '{\"encoded_by\":\"$urlApiRestUser\", \"encoding_step\":3}' "; + $cmdMajPod2 .= "$urlApiRestMeeting"; + $verificationMajPod2 = exec("$cmdMajPod2 2>&1", $aRetourVerificationMajPod2, $sRetourVerificationMajPod2); + + writeLog(" + Récupération de l'internal_meeting_id correspondant à l'objet bbb_meeting $bbbMeetingId depuis Pod", "DEBUG"); + $internalMeetingId = ""; + if ($sRetourVerificationMajPod2 == 0) { + writeLog(" - Commande '$cmdMajPod2' : $aRetourVerificationMajPod2[0]", "DEBUG"); + + // Recherche de l'internal_meeting_id correspondant au meeting + $oMeeting = json_decode($aRetourVerificationMajPod2[0]); + if (isset($oMeeting->internal_meeting_id)) { + $internalMeetingId = $oMeeting->internal_meeting_id; + } + } else { + writeLog(" - Commande '$cmdMajPod2' : $sRetourVerificationMajPod2[0]", "ERROR", __FILE__, __LINE__); + } + + if ($internalMeetingId != "") { + processDirectory($ligneLiveInProgressOnThisServer->idBbbLiveStreaming, $internalMeetingId); + } else { + writeLog(" - Impossible de récupérer l'internal meeting id pour le direct " . $ligneLiveInProgressOnThisServer->idBbbLiveStreaming, "ERROR", __FILE__, __LINE__); + } + } + } else { + writeLog(" + Commande '$cmdStop' : $sRetourVerificationStop[0]", "ERROR", __FILE__, __LINE__); + } + } + } else { + writeLog(" + Commande '$cmdGrep1' : $sRetourVerificationGrep1[0]", "ERROR", __FILE__, __LINE__); + } + } + } } /** * Procédure permettant de supprimer un diffuseur dans Pod. * @param string $broadcasterName - Nom du diffuseur à supprimer */ -function deleteBroadcaster($broadcasterName) { - // Via l'API, il faut utiliser le slug et non le nom - $slug = str_replace("[BBB]", "bbb", $broadcasterName); - /* Suppression du diffuseur correspondant dans Pod */ - $cmdBroadcaster = "curl --silent "; - $cmdBroadcaster .= "-H 'Authorization: Token " . POD_TOKEN . "' "; - $cmdBroadcaster .= "-X DELETE '" . checkEndWithoutSlash(POD_URL) . "/rest/broadcasters/$slug/'"; - $verificationBroadcaster = exec("$cmdBroadcaster 2>&1", $aRetourVerificationBroadcaster, $sRetourVerificationBroadcaster); - - writeLog(" + Suppression du diffuseur $slug dans Pod", "DEBUG"); - - if ($sRetourVerificationBroadcaster == 0) { - writeLog(" - Commande '$cmdBroadcaster' exécutée", "DEBUG"); - } - else { - writeLog(" + Commande '$cmdBroadcaster' : $aRetourVerificationBroadcaster[0]", "ERROR", __FILE__, __LINE__); - } +function deleteBroadcaster($broadcasterName) +{ + // Via l'API, il faut utiliser le slug et non le nom + $slug = str_replace("[BBB]", "bbb", $broadcasterName); + /* Suppression du diffuseur correspondant dans Pod */ + $cmdBroadcaster = "curl --silent "; + $cmdBroadcaster .= "-H 'Authorization: Token " . POD_TOKEN . "' "; + $cmdBroadcaster .= "-X DELETE '" . checkEndWithoutSlash(POD_URL) . "/rest/broadcasters/$slug/'"; + $verificationBroadcaster = exec("$cmdBroadcaster 2>&1", $aRetourVerificationBroadcaster, $sRetourVerificationBroadcaster); + + writeLog(" + Suppression du diffuseur $slug dans Pod", "DEBUG"); + + if ($sRetourVerificationBroadcaster == 0) { + writeLog(" - Commande '$cmdBroadcaster' exécutée", "DEBUG"); + } else { + writeLog(" + Commande '$cmdBroadcaster' : $aRetourVerificationBroadcaster[0]", "ERROR", __FILE__, __LINE__); + } } /** @@ -706,28 +730,29 @@ function deleteBroadcaster($broadcasterName) { * @param string $idBbbLiveStreaming - Identifiant du répertoire BigBlueButton-liveStreaming concerné. * @param string $internalMeetingId - Identifiant interne de la session BBB enregistrée. */ -function processDirectory($idBbbLiveStreaming, $internalMeetingId) { - writeLog("-----Copie des fichiers vidéos enregistrées sur le partage NFS, pour traitement automatique par POD : processDirectory($idBbbLiveStreaming, $internalMeetingId)-----", "DEBUG"); - // Parcours du répertoire videodata du répertoire BigBlueButton-liveStreaming concerné - // Définition du répertoire - $directoryLiveStreaming = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming$idBbbLiveStreaming/videodata"; - writeLog("Recherche de fichiers vidéos pour le direct $idBbbLiveStreaming : $directoryLiveStreaming", "DEBUG"); - if (file_exists($directoryLiveStreaming)) { - $listFiles = scandir("$directoryLiveStreaming"); - // Mise en place d'une boucle, mais il ne doit y avoir qu'un seul fichier au maximum - foreach ($listFiles as $key => $value) { - if (strrpos($value, ".mkv")) { - // Déplacer et renommer le fichier avec l'internalMeetingId - $oldFilename = "$directoryLiveStreaming/$value"; - $newFilename = checkEndSlash(POD_DEFAULT_BBB_PATH) . "$internalMeetingId" . ".mkv"; - writeLog(" + Déplacement du fichier $oldFilename vers $newFilename", "DEBUG"); - @rename("$oldFilename", "$newFilename"); - // Positionnement de droits adéquats pour pouvoir être encodé par Pod - // Normalement, il n'y en a pas besoin : le fichier généré a les droits 644, ce qui est suffisant. - @chmod("$newFilename", 0755); - } - } - } +function processDirectory($idBbbLiveStreaming, $internalMeetingId) +{ + writeLog("-----Copie des fichiers vidéos enregistrées sur le partage NFS, pour traitement automatique par POD : processDirectory($idBbbLiveStreaming, $internalMeetingId)-----", "DEBUG"); + // Parcours du répertoire videodata du répertoire BigBlueButton-liveStreaming concerné + // Définition du répertoire + $directoryLiveStreaming = checkEndSlash(PHYSICAL_BASE_ROOT) . "bbb-live-streaming$idBbbLiveStreaming/videodata"; + writeLog("Recherche de fichiers vidéos pour le direct $idBbbLiveStreaming : $directoryLiveStreaming", "DEBUG"); + if (file_exists($directoryLiveStreaming)) { + $listFiles = scandir("$directoryLiveStreaming"); + // Mise en place d'une boucle, mais il ne doit y avoir qu'un seul fichier au maximum + foreach ($listFiles as $key => $value) { + if (strrpos($value, ".mkv")) { + // Déplacer et renommer le fichier avec l'internalMeetingId + $oldFilename = "$directoryLiveStreaming/$value"; + $newFilename = checkEndSlash(POD_DEFAULT_BBB_PATH) . "$internalMeetingId" . ".mkv"; + writeLog(" + Déplacement du fichier $oldFilename vers $newFilename", "DEBUG"); + @rename("$oldFilename", "$newFilename"); + // Positionnement de droits adéquats pour pouvoir être encodé par Pod + // Normalement, il n'y en a pas besoin : le fichier généré a les droits 644, ce qui est suffisant. + @chmod("$newFilename", 0755); + } + } + } } /** @@ -735,75 +760,78 @@ function processDirectory($idBbbLiveStreaming, $internalMeetingId) { * Les messages au niveau debug ne seront écris que si l'application est configuré en mode DEBUG (DEBUG = true). * @param string $message - Message à écrire * @param string $level - Niveau de log de ce message (debug, info, warning, error) - * @param string $file - Nom du fichier PHP concerné (en cas d'erreur) + * @param string $file - Nom du fichier PHP concerné (en cas d'erreur) * @param int $line - Ligne dans le fichier PHP concerné (en cas d'erreur) * @return nombre d'octets écris, false sinon */ -function writeLog($message, $level, $file=null, $line=null) { - // Ecriture des lignes de debug seulement en cas de mode DEBUG - if (($level == "DEBUG") && (! DEBUG)) { - return false; - } - - // Création du répertoire des logs si besoin - if (! file_exists(checkEndSlash(PHYSICAL_LOG_ROOT))) { - // Création du répertoire - @mkdir(checkEndSlash(PHYSICAL_LOG_ROOT), 0755); - } - - // Configuration du fichier de log, 1 par jour - $logFile = checkEndSlash(PHYSICAL_LOG_ROOT) . gmdate("Y-m-d") . "_bbb-pod-live.log"; - - // En cas de non existence, on créé ce fichier - if (!file_exists($logFile)) { - $file = fopen($logFile, "x+"); - // Une exception est levée en cas de non existence du fichier (problème manifeste de droits utilisateurs) - if (!file_exists($logFile)) { - echo "Erreur de configuration : impossible de créer le fichier $logFile."; - throw new Exception("Impossible de créer le fichier $logFile."); - } - } - - // Une exception est levée en cas de problème d'écriture (problème manifeste de droits utilisateurs) - if(!is_writeable($logFile)) { - throw new Exception("$logFile n'a pas les droits en écriture."); - } - - $message = gmdate("Y-m-d H:i:s") . " - [$level] - " . $message; - $message .= is_null($file) ? '' : " - Fichier [$file]"; - $message .= is_null($line) ? '' : " - Ligne [$line]."; - $message .= "\n"; - - // Surcharge de la variable globale signifiant une erreur dans le script - if ($level == "ERROR") { - $GLOBALS["txtErrorInScript"] .= "$message"; - } - - return file_put_contents( $logFile, $message, FILE_APPEND ); +function writeLog($message, $level, $file = null, $line = null) +{ + // Ecriture des lignes de debug seulement en cas de mode DEBUG + if (($level == "DEBUG") && (! DEBUG)) { + return false; + } + + // Création du répertoire des logs si besoin + if (! file_exists(checkEndSlash(PHYSICAL_LOG_ROOT))) { + // Création du répertoire + @mkdir(checkEndSlash(PHYSICAL_LOG_ROOT), 0755); + } + + // Configuration du fichier de log, 1 par jour + $logFile = checkEndSlash(PHYSICAL_LOG_ROOT) . gmdate("Y-m-d") . "_bbb-pod-live.log"; + + // En cas de non existence, on créé ce fichier + if (!file_exists($logFile)) { + $file = fopen($logFile, "x+"); + // Une exception est levée en cas de non existence du fichier (problème manifeste de droits utilisateurs) + if (!file_exists($logFile)) { + echo "Erreur de configuration : impossible de créer le fichier $logFile."; + throw new Exception("Impossible de créer le fichier $logFile."); + } + } + + // Une exception est levée en cas de problème d'écriture (problème manifeste de droits utilisateurs) + if (!is_writeable($logFile)) { + throw new Exception("$logFile n'a pas les droits en écriture."); + } + + $message = gmdate("Y-m-d H:i:s") . " - [$level] - " . $message; + $message .= is_null($file) ? '' : " - Fichier [$file]"; + $message .= is_null($line) ? '' : " - Ligne [$line]."; + $message .= "\n"; + + // Surcharge de la variable globale signifiant une erreur dans le script + if ($level == "ERROR") { + $GLOBALS["txtErrorInScript"] .= "$message"; + } + + return file_put_contents($logFile, $message, FILE_APPEND); } /** * Fonction permettant de vérifier que la chaîne de caractères finit par un /. En ajoute un si nécessaire. - * @param string - Chaîne de caractères. + * @param string - Chaîne de caractères. * @return Chaîne de caractères identique à celle en entrée, mais avec un / comme dernier caractère. */ -function checkEndSlash($string) { - if (substr($string, -1) !== "/" ) { - $string .= "/"; - } - return $string; +function checkEndSlash($string) +{ + if (substr($string, -1) !== "/") { + $string .= "/"; + } + return $string; } /** * Fonction permettant de vérifier que la chaîne de caractères ne finit pas par un /. Supprime ce / un si nécessaire. - * @param string - Chaîne de caractères. + * @param string - Chaîne de caractères. * @return Chaîne de caractères identique à celle en entrée, mais sans / à la fin. */ -function checkEndWithoutSlash($string) { - if (substr($string, -1) == "/" ) { - $string = substr($string, 0, -1); - } - return $string; +function checkEndWithoutSlash($string) +{ + if (substr($string, -1) == "/") { + $string = substr($string, 0, -1); + } + return $string; } /** @@ -812,14 +840,15 @@ function checkEndWithoutSlash($string) { * @param $string - chaîne avec accents * @return chaîne sans accents */ -function formatString($string) { - $string = htmlentities($string, ENT_NOQUOTES, 'utf-8'); - $string = preg_replace('#&([A-za-z])(?:uml|circ|tilde|acute|grave|cedil|ring);#', '\1', $string); - $string = preg_replace('#&([A-za-z]{2})(?:lig);#', '\1', $string); - $string = preg_replace('#&[^;]+;".()\'#', '', $string); - $string = preg_replace('/\s+/', '-', $string); - $string = str_replace("'", "-", $string); - return $string; +function formatString($string) +{ + $string = htmlentities($string, ENT_NOQUOTES, 'utf-8'); + $string = preg_replace('#&([A-za-z])(?:uml|circ|tilde|acute|grave|cedil|ring);#', '\1', $string); + $string = preg_replace('#&([A-za-z]{2})(?:lig);#', '\1', $string); + $string = preg_replace('#&[^;]+;".()\'#', '', $string); + $string = preg_replace('/\s+/', '-', $string); + $string = str_replace("'", "-", $string); + return $string; } /** @@ -828,13 +857,14 @@ function formatString($string) { * @param $string - chaîne avec accents * @return chaîne sans accents */ -function formatStringToDisplay($string) { - $string = htmlentities($string, ENT_NOQUOTES, 'utf-8'); - $string = preg_replace('#&([A-za-z])(?:uml|circ|tilde|acute|grave|cedil|ring);#', '\1', $string); - $string = preg_replace('#&([A-za-z]{2})(?:lig);#', '\1', $string); - $string = preg_replace('#&[^;]+;".()\'#', '', $string); - $string = str_replace("'", "-", $string); - return $string; +function formatStringToDisplay($string) +{ + $string = htmlentities($string, ENT_NOQUOTES, 'utf-8'); + $string = preg_replace('#&([A-za-z])(?:uml|circ|tilde|acute|grave|cedil|ring);#', '\1', $string); + $string = preg_replace('#&([A-za-z]{2})(?:lig);#', '\1', $string); + $string = preg_replace('#&[^;]+;".()\'#', '', $string); + $string = str_replace("'", "-", $string); + return $string; } /** @@ -842,13 +872,13 @@ function formatStringToDisplay($string) { * @param $subject - sujet du mail * @param $message - message du mail */ -function sendEmail($subject, $message) { - $to = ADMIN_EMAIL; - $message = nl2br($message); +function sendEmail($subject, $message) +{ + $to = ADMIN_EMAIL; + $message = nl2br($message); - $headers = "MIME-Version: 1.0\r\n"; - $headers .= "Content-type: text/html; charset=utf-8\r\n"; + $headers = "MIME-Version: 1.0\r\n"; + $headers .= "Content-type: text/html; charset=utf-8\r\n"; - mail ($to, $subject, $message, $headers); -} -?> \ No newline at end of file + mail($to, $subject, $message, $headers); +} diff --git a/setup.cfg b/setup.cfg index d0437d40a1..881dd5d576 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,4 +6,17 @@ ignore = # E203: whitespace before ':' E203, # W503: line break before binary operator - W503 \ No newline at end of file + W503, + + # flake8-annotations + # Missing type annotation for *args + ANN002, + # Missing type annotation for **kwargs + ANN003, + # Missing type annotation for self in method + ANN101, + # Missing type annotation for cls in classmethod + ANN102, + # Missing return type annotation for special method (__init__, ) + ANN204 + From e71056114348b05be5700409faf562ed00e88cc5 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 9 Apr 2024 07:32:15 +0000 Subject: [PATCH 08/37] Auto-update configuration files --- CONFIGURATION_FR.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CONFIGURATION_FR.md b/CONFIGURATION_FR.md index 7f2d4debe3..fb24a7c5f4 100644 --- a/CONFIGURATION_FR.md +++ b/CONFIGURATION_FR.md @@ -599,9 +599,11 @@ Vous pouvez tout à fait rajouter des langues comme vous le souhaitez. Il faudra - `SECURE_SSL_REDIRECT` - > valeur par défaut : `not DEBUG` - + > valeur par défaut : `False` + >> À moins que votre site ne doive être disponible sur des connexions SSL et non SSL,
+ >> vous souhaiterez probablement définir ce paramètre sur True ou configurer un
+ >> load balancer ou reverse-proxy pour rediriger toutes les connexions vers HTTPS.
- `SESSION_COOKIE_AGE` From 6ae5cf2358b65a96cb5c850633c34ccaae384695 Mon Sep 17 00:00:00 2001 From: Olivier Bado-Faustin Date: Wed, 10 Apr 2024 10:43:56 +0200 Subject: [PATCH 09/37] [DONE] Improve bbb export script (#1093) * * Adapt script to Mysql/MariaDB * DB_PARAMS is now a dict with sevral DB (key is the bbb-origin-server-name from BBB records) * Add IGNORED_SERVERS param for a list of origin_server_name to be ignored * Add USE_CACHE param to load BBB local xml instead of asking again BBB server * Add new option "use_export_csv" to export a CSV list of recordings * Flake 8 ignore * Comment "daemonize" and add "logto" uwsgi params --- .coveragerc | 1 - Makefile | 8 +- .../commands/migrate_bbb_recordings.py | 433 +++++++++++++----- pod_uwsgi.ini | 10 + 4 files changed, 319 insertions(+), 133 deletions(-) diff --git a/.coveragerc b/.coveragerc index 60de8f7942..ce808f4bfb 100755 --- a/.coveragerc +++ b/.coveragerc @@ -14,6 +14,5 @@ omit = pod/*settings*.py */migrations/* pod/recorder/plugins/type_*.py pod/*/forms.py - pod/video/bbb.py scripts/bbb-pod-live/*.* pod/live/pilotingInterface.py diff --git a/Makefile b/Makefile index 81fac324de..c542406ed0 100755 --- a/Makefile +++ b/Makefile @@ -156,6 +156,7 @@ else ifeq ($(DOCKER_ENV), full-test) else @$(COMPOSE) down -v endif + # Arrête le serveur de test et supprime les fichiers générés docker-reset: ifeq ($(DOCKER_ENV), full) @@ -165,16 +166,11 @@ else ifeq ($(DOCKER_ENV), full-test) else @$(COMPOSE) down -v endif - # sudo rm -rf ./pod/log + # Supprime les fichiers générés. sudo rm -rf ./pod/log - # sudo rm -rf ./pod/static sudo rm -rf ./pod/static - # sudo rm -rf ./pod/node_modules sudo rm -rf ./pod/node_modules - # sudo rm -rf ./pod/db_migrations sudo rm -rf ./pod/db_migrations - # sudo rm -rf ./pod/db.sqlite3 sudo rm -rf ./pod/db.sqlite3 sudo rm -rf ./pod/db_remote.sqlite3 - # sudo rm -rf ./pod/media sudo rm -rf ./pod/media diff --git a/pod/video/management/commands/migrate_bbb_recordings.py b/pod/video/management/commands/migrate_bbb_recordings.py index 642f9ecb0a..8937805df9 100644 --- a/pod/video/management/commands/migrate_bbb_recordings.py +++ b/pod/video/management/commands/migrate_bbb_recordings.py @@ -57,7 +57,7 @@ to an administrator. In this way, for records from sources other than Pod or Moodle, they will automatically be associated with an administrator (unless this script is modified). -It is also planned (if access to the Moodle database is write-only, of course) to add +It is also planned (if access to the Moodle database is writable, of course) to add information directly to the BBB session in Moodle (intro field). This is made possible by using the --use-import-video parameter, the --use-database-moodle parameter and setup directly in this file. @@ -106,20 +106,28 @@ from pod.recorder.models import Recorder from xml.dom import minidom -# For PostgreSQL database # -# Don't forget to run the following command the 1st time -# pip install psycopg2-binary -import psycopg2 -import psycopg2.extras -from psycopg2 import sql - -# For MariaDB/MySQL database # -# Don't forget to run the following command the 1st time -# pip install mysql-connector-python -# import mysql.connector +# # Script config (TO EDIT) # # +# Moodle database engine (postgresql, mysql or None) +MOODLE_DB_TYPE = None + +MOODLE_DB_TYPE = "postgresql" + +if MOODLE_DB_TYPE == "postgresql": + # For PostgreSQL database # + # Don't forget to run the following command the 1st time + # pip install psycopg2-binary + from psycopg2 import connect + import psycopg2.extras + from psycopg2 import sql + from psycopg2 import Error as DBError +elif MOODLE_DB_TYPE == "mysql": + # For MariaDB/MySQL database # + # Don't forget to run the following command the 1st time + # pip install mysql-connector-python + from mysql.connector import connect + from mysql.connector import Error as DBError -# # Script config (TO EDIT) # # # Old BigBlueButton config # # Old BigBlueButton/Scalelite server URL SCRIPT_BBB_SERVER_URL = "https://bbb.univ.fr/" @@ -143,33 +151,51 @@ # # # use-database-moodle # -# Moodle database connection parameters +# Moodle databases connection parameters DB_PARAMS = { - "host": "bddmoodle.univ.fr", - "database": "moodle", - "user": "moodle", - "password": "", - "port": "", - "connect_timeout": "10", + # The default Moodle DB (if a recording has no bbb-origin-server-name) + "default": { + "host": "bddmoodle.univ.fr", + "database": "moodle", + "user": "moodle", + "password": "", + "port": None, + "connect_timeout": 10, + }, + # Add as many Moodle DB as bbb-origin-server-name you have + "server2": { + "host": "bddmoodle.univ.fr", + "database": "moodle2", + "user": "moodle2", + "password": "", + "port": None, + "connect_timeout": 10, + }, } + +# List of origin_server_name to be ignored by this script +IGNORED_SERVERS = ["not-a-moodle.univ.fr"] + +# Site domain (like pod.univ.fr) +SITE_DOMAIN = get_current_site(None).domain + # Information message set in Moodle database, table mdl_bigbluebuttonbn, field intro SCRIPT_INFORM = ( "

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

" + "Accéder au module d’import des vidéos dans Pod." + "

" % (SITE_DOMAIN, SITE_DOMAIN, SITE_DOMAIN) ) # # # # # # @@ -183,23 +209,116 @@ # Global variable number_records_to_encode = 0 +# Ask BBB or load previous xml +USE_CACHE = False + + +class Generic_user: + """Class for a generic user.""" + + def __init__( + self, user_id: str, username: str, firstname: str, lastname: str, email: str + ): + """Initialize.""" + self.id = user_id + self.username = username + self.firstname = firstname + self.lastname = lastname + self.email = email -def connect_moodle_database(): + def __str__(self): + """Display a generic user object as string.""" + if self.id: + return "%s %s (%s)" % (self.firstname, self.lastname, self.username) + else: + return "None" + + +class Generic_recording: + """Class for a generic recording.""" + + # Optional BBB recording fields + origin_server_name = "" + origin_version = "" + origin_context = "" + recording_name = "" + origin_id = "" + origin_label = "" + description = "" + published = "" + state = "" + + def __init__( + self, + internal_meeting_id, + meeting_id, + meeting_name, + start_time, + origin, + presentation_url, + video_url, + ): + """Initialize.""" + self.internal_meeting_id = internal_meeting_id + self.meeting_id = meeting_id + self.meeting_name = meeting_name + self.start_time = start_time + self.origin = origin + self.presentation_url = presentation_url + self.video_url = video_url + # Generated formatted date + self.start_date = dt.fromtimestamp(float(start_time) / 1000) + # Generated source URL: video playback if possible + self.source_url = self.video_url + if self.source_url == "": + # Else presentation playback + self.source_url = self.presentation_url + + def as_list(self) -> list: + """Get a Generic_recording as list.""" + return [ + self.meeting_name, + self.recording_name, + self.start_date.strftime("%Y-%m-%d"), + self.origin, + self.origin_server_name, + self.origin_version, + self.origin_context, + self.origin_id, + self.origin_label, + self.description, + self.published, + self.state, + self.source_url, + ] + + def __str__(self): + """Get a Generic_recording as string.""" + return "%s,%s,%s,%s" % ( + self.meeting_name, + self.start_date, + self.origin, + self.source_url, + ) + + +def connect_moodle_database(generic_recording=None): """Connect to the Moodle database and returns cursor.""" + if generic_recording and generic_recording.origin_server_name: + server = generic_recording.origin_server_name + else: + server = "default" + try: - # For Postgre database - connection = psycopg2.connect(**DB_PARAMS) - cursor = connection.cursor(cursor_factory=psycopg2.extras.DictCursor) - # For MariaDB/MySQL database - # connection = mysql.connector.connect(**DB_PARAMS) - # cursor = connection.cursor() + connection = connect(**DB_PARAMS[server]) + if MOODLE_DB_TYPE == "postgresql": + cursor = connection.cursor(cursor_factory=psycopg2.extras.DictCursor) + else: + cursor = connection.cursor() return connection, cursor - # For MariaDB/MySQL database - # except mysql.connector.Error as e: - # For Postgre database - except psycopg2.Error as e: - print("Error: Unable to connect to the Moodle database.") + except DBError as e: + print("Error: Unable to connect to the Moodle database for server `%s`." % server) print(e) return None, None @@ -216,7 +335,7 @@ def disconnect_moodle_database(connection, cursor): print(e) -def process(options): +def process(options): # noqa: C901 """Achieve the BBB recordings migration.""" # Get the BBB recordings from BBB/Scalelite server API recordings = get_bbb_recordings_by_xml() @@ -230,6 +349,7 @@ def process(options): # Manage the recordings i = 0 + record_strings = [] for recording in recordings: i += 1 # Only recordings within the interval are taken into account. @@ -258,11 +378,51 @@ def process(options): ) process_recording_to_import_video(options, generic_recording) print("------------------------------") + elif options["use_export_csv"]: + # #3 Use Export recordings as CSV + print( + "\n#%s ; %s" % (str(i), generic_recording) + ) + + line = generic_recording.as_list() + if options["use_database_moodle"]: + generic_owners = get_created_in_moodle(generic_recording) + generic_owners = [str(o) for o in generic_owners] + line.append(generic_owners) + + record_strings.append(line) + if options["use_export_csv"]: + header = [ + "meeting_name", + "recording_name", + "start_date", + "origin", + "origin_server_name", + "origin_version", + "origin_context", + "origin_id", + "origin_label", + "description", + "published", + "state", + "source_url", + ] + + if options["use_database_moodle"]: + header.append("Moodle_owners") + import csv + + with open("out.csv", "w", newline="") as f: + writer = csv.writer( + f, delimiter=";", quotechar='"', quoting=csv.QUOTE_MINIMAL + ) + writer.writerow(header) + writer.writerows(record_strings) # Number of recordings to encode print("***Number of records to encode in video: %s***" % number_records_to_encode) -def get_bbb_recordings_by_xml(): +def get_bbb_recordings_by_xml() -> list: """Get the BBB recordings from BBB/Scalelite server.""" print("\n*** Get the BBB recordings from BBB/Scalelite server. ***") recordings = [] @@ -275,12 +435,21 @@ def get_bbb_recordings_by_xml(): # Request on BBB/Scalelite server (API) # URL example: # https://bbb.univ.fr/bigbluebutton/api/getRecordings?checksum=xxxx - urlToRequest = SCRIPT_BBB_SERVER_URL - urlToRequest += "bigbluebutton/api/getRecordings?checksum=" + checksum - addr = requests.get(urlToRequest) - print("Request on URL: " + urlToRequest + ", status: " + str(addr.status_code)) - # XML result to parse - xmldoc = minidom.parseString(addr.text) + if USE_CACHE is False: + urlToRequest = SCRIPT_BBB_SERVER_URL + urlToRequest += "bigbluebutton/api/getRecordings?checksum=" + checksum + addr = requests.get(urlToRequest) + print( + "Request on URL: " + urlToRequest + ", status: " + str(addr.status_code) + ) + with open("getRecordings.xml", "w") as f: + f.write(addr.text) + print("BBB Response saved to `getRecordings.xml`.") + # XML result to parse + xmldoc = minidom.parseString(addr.text) + else: + xmldoc = minidom.parse("getRecordings.xml") + print("Parsing `getRecordings.xml`...") returncode = xmldoc.getElementsByTagName("returncode")[0].firstChild.data # Management of FAILED error (basically error in checksum) if returncode == "FAILED": @@ -298,7 +467,7 @@ def get_bbb_recordings_by_xml(): return recordings -def get_recording(recording): # noqa: C901 +def get_recording(recording) -> Generic_recording: # noqa: C901 """Return a BBB recording, using the Generic_recording class.""" generic_recording = None try: @@ -343,25 +512,45 @@ def get_recording(recording): # noqa: C901 generic_recording = Generic_recording( internal_meeting_id, meeting_id, - meeting_name, + meeting_name.strip(), start_time, origin, presentation_url, video_url, ) + + # Get other optional BBB tags + for name, tag in [ + ("origin_server_name", "bbb-origin-server-name"), + ("origin_version", "bbb-origin-version"), + ("origin_context", "bbb-context"), + ("origin_id", "bbb-context-id"), + ("origin_label", "bbb-context-label"), + ("description", "bbb-recording-description"), + ("published", "published"), + ("state", "state"), + ("recording_name", "bbb-recording-name"), + ]: + # Check that tag exists + if recording.getElementsByTagName(tag): + # Check that tag is not empty + if recording.getElementsByTagName(tag)[0].firstChild: + value = recording.getElementsByTagName(tag)[0].firstChild.data + setattr(generic_recording, name, value.strip()) + except Exception as e: err = "Problem to get BBB recording: " + str(e) + ". " + traceback.format_exc() print(err) return generic_recording -def get_video_file_name(file_name, date, extension): +def get_video_file_name(file_name: str, date: dt, extension: str) -> str: """Normalize a video file name.""" slug = slugify("%s %s" % (file_name[0:40], str(date)[0:10])) return "%s.%s" % (slug, extension) -def download_bbb_video_file(source_url, dest_file): +def download_bbb_video_file(source_url: str, dest_file: str): """Download a BBB video playback.""" session = requests.Session() # Download and parse the remote HTML file (BBB specific) @@ -457,7 +646,8 @@ def process_recording_to_import_video(options, generic_recording): # Search if this recording was made with Moodle (if configured) if options["use_database_moodle"]: generic_owners = get_created_in_moodle(generic_recording) - msg = "BBB session made with Moodle." + if generic_owners: + msg = "BBB session made with Moodle." if generic_owners: msg += " Create user in Pod if necessary." # Owners found in Moodle @@ -466,14 +656,31 @@ def process_recording_to_import_video(options, generic_recording): pod_owner = get_or_create_user_pod(options, generic_owner) # Create the external recording for an owner, if necessary manage_external_recording(options, generic_recording, site, pod_owner, msg) - # Update information in Moodle (field intro) - set_information_in_moodle(options, generic_recording) + if options["use_database_moodle"]: + # Update information in Moodle (field intro) + set_information_in_moodle(options, generic_recording) else: # Only 1 owner (administrator at least if not found) manage_external_recording(options, generic_recording, site, owner, msg) -def get_created_in_pod(generic_recording): +def process_recording_to_export_csv(options, generic_recording) -> str: + """Convert a recording to a comma separated values.""" + print( + " - Recording %s, playback %s" + "create an external recording if necessary." + % ( + generic_recording.internal_meeting_id, + generic_recording.source_url, + ) + ) + return "%s,%s" % ( + generic_recording.internal_meeting_id, + generic_recording.source_url, + ) + + +def get_created_in_pod(generic_recording: Generic_recording) -> Meeting: """Allow to know if this recording was made with Pod. In such a case, we know the meeting (owner information). @@ -483,7 +690,7 @@ def get_created_in_pod(generic_recording): return meeting -def get_or_create_user_pod(options, generic_owner): +def get_or_create_user_pod(options, generic_owner: Generic_user): """Return the Pod user corresponding to the generic owner. If necessary, create this user in Pod. @@ -528,7 +735,7 @@ def manage_external_recording(options, generic_recording, site, owner, msg): create_external_recording(generic_recording, site, owner) -def create_external_recording(generic_recording, site, owner): +def create_external_recording(generic_recording: Generic_recording, site, owner): """Create an external recording for a BBB recording, if necessary.""" # Check if external recording already exists for this owner external_recording = ExternalRecording.objects.filter( @@ -546,25 +753,31 @@ def create_external_recording(generic_recording, site, owner): ) -def get_created_in_moodle(generic_recording): # noqa: C901 +def get_created_in_moodle(generic_recording: Generic_recording): # noqa: C901 """Allow to know if this recording was made with Moodle. In such a case, we know the list of owners. """ # Default value owners_found = [] + # Do not search in IGNORED_SERVERS + if generic_recording.origin_server_name in IGNORED_SERVERS: + print("origin_server_name ignored (%s)." % generic_recording.origin_server_name) + return owners_found try: participants = "" - connection, cursor = connect_moodle_database() + connection, cursor = connect_moodle_database(generic_recording) with cursor as c: - select_query = sql.SQL( + select_query = ( "SELECT b.id, b.intro, b.course, b.participants FROM " "public.mdl_bigbluebuttonbn_recordings r, public.mdl_bigbluebuttonbn b " "WHERE r.bigbluebuttonbnid = b.id " "AND r.recordingid = '%s' " "AND r.status = 2" % (generic_recording.internal_meeting_id) - ).format(sql.Identifier("type")) + ) + if MOODLE_DB_TYPE == "postgresql": + select_query = sql.SQL(select_query).format(sql.Identifier("type")) c.execute(select_query) results = c.fetchall() for res in results: @@ -577,20 +790,25 @@ def get_created_in_moodle(generic_recording): # noqa: C901 # Parse participants as a JSON string parsed_data = json.loads(participants) for item in parsed_data: + print("%s participants in this recording." % len(parsed_data)) # Search for moderators if ( item["selectiontype"] == "user" and item["role"] == "moderator" ): user_id_moodle = item["selectionid"] - user_moodle = get_moodle_user(user_id_moodle) + user_moodle = get_moodle_user( + user_id_moodle, connection, cursor + ) if user_moodle: print( " - Moderator found in Moodle: %s %s" % (user_moodle.username, user_moodle.email) ) owners_found.append(user_moodle) - + else: + print("No participant in this recording.") + disconnect_moodle_database(connection, cursor) except Exception as e: err = ( "Problem to find moderators for BBB recording in Moodle" @@ -608,7 +826,7 @@ def set_information_in_moodle(options, generic_recording): Use SCRIPT_INFORM. """ try: - connection, cursor = connect_moodle_database() + connection, cursor = connect_moodle_database(generic_recording) with cursor as c: # Request for Moodle v4 select_query = sql.SQL( @@ -648,11 +866,10 @@ def set_information_in_moodle(options, generic_recording): print(err) -def get_moodle_user(user_id): +def get_moodle_user(user_id: str, connection, cursor) -> Generic_user: """Return a generic user by user id in Moodle database.""" dict_user = [] generic_user = None - connection, cursor = connect_moodle_database() with cursor as c: # Most important field: username select_query = sql.SQL( @@ -672,7 +889,7 @@ def get_moodle_user(user_id): return generic_user -def convert_format(source_url, internal_meeting_id): +def convert_format(source_url: str, internal_meeting_id: str): """Convert presentation playback URL if necessary (see SCRIPT_PLAYBACK_URL_23).""" try: # Conversion - if necessary - from @@ -686,7 +903,7 @@ def convert_format(source_url, internal_meeting_id): err = "Problem to convert format: " + str(e) + ". " + traceback.format_exc() print(err) - return source_url + return source_url.strip() def check_system(options): # noqa: C901 @@ -722,69 +939,27 @@ def check_system(options): # noqa: C901 ) if options["use_database_moodle"]: # Check connection to Moodle database - connection, cursor = connect_moodle_database() - if not cursor: + if MOODLE_DB_TYPE is None: error = True print( - "ERROR: Unable to connect to Moodle database. Please configure " - "DB_PARAMS in this file, check firewall rules and permissions." + "ERROR: Undefined MOODLE_DB_TYPE." + " Please set your Moodle DB type (postgresql or mysql)." ) else: - disconnect_moodle_database(connection, cursor) + connection, cursor = connect_moodle_database() + if not cursor: + error = True + print( + "ERROR: Unable to connect to Moodle database. Please configure " + "DB_PARAMS in this file, check firewall rules and permissions." + ) + else: + disconnect_moodle_database(connection, cursor) if error: exit() -class Generic_user: - """Class for a generic user.""" - - def __init__(self, user_id, username, firstname, lastname, email): - """Initialize.""" - self.id = user_id - self.username = username - self.firstname = firstname - self.lastname = lastname - self.email = email - - def __str__(self): - """Display a generic user object as string.""" - if self.id: - return "%s %s (%s)" % (self.firstname, self.lastname, self.username) - else: - return "None" - - -class Generic_recording: - """Class for a generic recording.""" - - def __init__( - self, - internal_meeting_id, - meeting_id, - meeting_name, - start_time, - origin, - presentation_url, - video_url, - ): - """Initialize.""" - self.internal_meeting_id = internal_meeting_id - self.meeting_id = meeting_id - self.meeting_name = meeting_name - self.start_time = start_time - self.origin = origin - self.presentation_url = presentation_url - self.video_url = video_url - # Generated formatted date - self.start_date = dt.fromtimestamp(float(start_time) / 1000) - # Generated source URL: video playback if possible - self.source_url = self.video_url - if self.source_url == "": - # Else presentation playback - self.source_url = self.presentation_url - - class Command(BaseCommand): """Migrate BBB recordings into the Pod database.""" @@ -804,6 +979,12 @@ def add_arguments(self, parser): default=False, help="Use import video module to get recordings (default=False)?", ) + parser.add_argument( + "--use_export_csv", + action="store_true", + default=False, + help="Export BBB recordings in csv format (default=False)?", + ) parser.add_argument( "--use-database-moodle", action="store_true", diff --git a/pod_uwsgi.ini b/pod_uwsgi.ini index 86e6736347..2024840f47 100644 --- a/pod_uwsgi.ini +++ b/pod_uwsgi.ini @@ -24,3 +24,13 @@ vacuum = true # When it occurs, uwsgi rejects those rejects with error "invalid request block size" and nginx returns HTTP 502. # Allowing 8k is a safe value that still allows weird long cookies set on .univ-xxx.fr buffer-size = 8192 + + +# Uncomment one of `logto` or `daemonize` to print log in a file. +# daemonize = /home/pod/django_projects/podv3/uwsgi/uwsgi-pod.log +# logto = /home/pod/django_projects/podv3/uwsgi/uwsgi-pod.log + +# recommended params by https://www.techatbloomberg.com/blog/configuring-uwsgi-production-deployment/ +strict = true ; This option tells uWSGI to fail to start if any parameter in the configuration file isn’t explicitly understood. +die-on-term = true ; Shutdown when receiving SIGTERM (default is respawn) +need-app = true ; This parameter prevents uWSGI from starting if it is unable to find or load your application module. From 5cf14911d86d702dd455958f79521893dd99e620 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 10 Apr 2024 08:44:27 +0000 Subject: [PATCH 10/37] Fixup. Format code with Black --- pod/video/management/commands/migrate_bbb_recordings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pod/video/management/commands/migrate_bbb_recordings.py b/pod/video/management/commands/migrate_bbb_recordings.py index 8937805df9..642dd4619f 100644 --- a/pod/video/management/commands/migrate_bbb_recordings.py +++ b/pod/video/management/commands/migrate_bbb_recordings.py @@ -380,9 +380,7 @@ def process(options): # noqa: C901 print("------------------------------") elif options["use_export_csv"]: # #3 Use Export recordings as CSV - print( - "\n#%s ; %s" % (str(i), generic_recording) - ) + print("\n#%s ; %s" % (str(i), generic_recording)) line = generic_recording.as_list() if options["use_database_moodle"]: From cc21f8e33c6da892c576b18ca503d29e3aac3b6d Mon Sep 17 00:00:00 2001 From: fanfounet Date: Wed, 10 Apr 2024 16:50:26 +0200 Subject: [PATCH 11/37] [DONE] WEBTV - feature dynamic home page (#1082) * feature for a dyanamic home page * flake * fix model * fixe lang * compilemessages * init data * fix * little fixes * black formatter * [DONE] Fix dressing Watermark path (#1048) * * Replace `settings.BASE_DIR` by watermark.file.path for video dressing watermark * Some code formatting & docs * Send email to admin on encoding error * * Setup of Video & Audio encoding test case is only called once (This testCase will be 4x faster) * another try to run setup only once * Fix small bug * correct typo * small correction * modify encoding test * * Polish `change video owner` template * move every "mandatory fields" help messagre in a main template to be included * Small change on encoding error handling * Minor QoC * correct typos * Corrections after peer review: * Use django url block * Use {{ owner }} and {{ user }} default str * Various QoC * Add missing parameter types * Specify types of some lists in functions hints * Remove every list content type hint, as they appear only in python >= 3.9 * Fixup. Format code with Prettier * Fixup. Format code with Black * Auto-update configuration files * first correction after the first review * Squashed commit of the following: commit c104e93a24d60758d8ef83b798a4c96f9f0078b8 Author: github-actions Date: Thu Feb 15 10:14:37 2024 +0000 Fixup. Format code with Black commit ac8bf7e3d3c92101e913cb00f6721ba160987760 Author: pampletousse <51699553+pampletousse@users.noreply.github.com> Date: Thu Feb 15 11:14:10 2024 +0100 [DONE] Pampletousse/fix improve bulk update tests (#1053) * Change default sort dashboard videos title into date_added * Change title to type test and add owner test * Change get videos objects * Change values for owner test * Change user for post request * Add test_bulk_update_tags and fix owner bulk update (missing move file) * Fix pep8 * Try to repair test_update_video_owner * Change order of tests + add bulk_delete test * Try to repair bulk_delete test * Try to repair bulk_delete test with user3 videos * add response check of bulk_delete failure * Fix bad value sending to change owner * Fix again test_views not working * Add doc on test_update_video_owner --------- Co-authored-by: pampletousse commit 033f7ae44287de60755e7d7bf97d87254997138a Author: github-actions Date: Thu Feb 15 09:24:39 2024 +0000 Auto-update configuration files commit d5887810ba4b7edba9a1a0fd94ea07fa03e3cca4 Author: github-actions Date: Thu Feb 15 09:24:17 2024 +0000 Fixup. Format code with Black commit 8770413309bd41eb7ad8546f3c9e8c82b936a84a Author: github-actions Date: Thu Feb 15 09:24:08 2024 +0000 Fixup. Format code with Prettier commit 2d982a819bf2117b901167eeeba72e323bb4f949 Author: Olivier Bado-Faustin Date: Thu Feb 15 10:23:39 2024 +0100 [DONE] Fix dressing Watermark path (#1048) * * Replace `settings.BASE_DIR` by watermark.file.path for video dressing watermark * Some code formatting & docs * Send email to admin on encoding error * * Setup of Video & Audio encoding test case is only called once (This testCase will be 4x faster) * another try to run setup only once * Fix small bug * correct typo * small correction * modify encoding test * * Polish `change video owner` template * move every "mandatory fields" help messagre in a main template to be included * Small change on encoding error handling * Minor QoC * correct typos * Corrections after peer review: * Use django url block * Use {{ owner }} and {{ user }} default str * Various QoC * Add missing parameter types * Specify types of some lists in functions hints * Remove every list content type hint, as they appear only in python >= 3.9 * [DONE] Fix dressing Watermark path (#1048) * * Replace `settings.BASE_DIR` by watermark.file.path for video dressing watermark * Some code formatting & docs * Send email to admin on encoding error * * Setup of Video & Audio encoding test case is only called once (This testCase will be 4x faster) * another try to run setup only once * Fix small bug * correct typo * small correction * modify encoding test * * Polish `change video owner` template * move every "mandatory fields" help messagre in a main template to be included * Small change on encoding error handling * Minor QoC * correct typos * Corrections after peer review: * Use django url block * Use {{ owner }} and {{ user }} default str * Various QoC * Add missing parameter types * Specify types of some lists in functions hints * Remove every list content type hint, as they appear only in python >= 3.9 * Fixup. Format code with Prettier * Fixup. Format code with Black * [DONE] Pampletousse/fix improve bulk update tests (#1053) * Change default sort dashboard videos title into date_added * Change title to type test and add owner test * Change get videos objects * Change values for owner test * Change user for post request * Add test_bulk_update_tags and fix owner bulk update (missing move file) * Fix pep8 * Try to repair test_update_video_owner * Change order of tests + add bulk_delete test * Try to repair bulk_delete test * Try to repair bulk_delete test with user3 videos * add response check of bulk_delete failure * Fix bad value sending to change owner * Fix again test_views not working * Add doc on test_update_video_owner --------- Co-authored-by: pampletousse * Fixup. Format code with Black * compilemessages * make lang + compilemessages * Squashed commit of the following: commit 793d1d033a0647754f2b0e903de64286f36b9f33 Author: github-actions Date: Thu Feb 15 13:29:30 2024 +0000 Fixup. Format code with Black commit 6fd67344d7024396f7427044900884cff0ae5a9b Author: Ptitloup Date: Thu Feb 15 14:28:59 2024 +0100 [DONE] Ptitloup/feature add video token (#1052) * add video token models, add it in admin, add view to manage token, call it from edit and views template * add access to video with token, improve token list display and add translation * fix indent * add uniq constraints for video access token, add unit test for video access token model, fix translate * add unit test for view, complete unit test for model, improve access token view * add copy to clipboard icon, add video access test view * add js to copy link and embed value * change col size and align middle * improve indent, add translation and pydoc * add info to delete token commit c104e93a24d60758d8ef83b798a4c96f9f0078b8 Author: github-actions Date: Thu Feb 15 10:14:37 2024 +0000 Fixup. Format code with Black commit ac8bf7e3d3c92101e913cb00f6721ba160987760 Author: pampletousse <51699553+pampletousse@users.noreply.github.com> Date: Thu Feb 15 11:14:10 2024 +0100 [DONE] Pampletousse/fix improve bulk update tests (#1053) * Change default sort dashboard videos title into date_added * Change title to type test and add owner test * Change get videos objects * Change values for owner test * Change user for post request * Add test_bulk_update_tags and fix owner bulk update (missing move file) * Fix pep8 * Try to repair test_update_video_owner * Change order of tests + add bulk_delete test * Try to repair bulk_delete test * Try to repair bulk_delete test with user3 videos * add response check of bulk_delete failure * Fix bad value sending to change owner * Fix again test_views not working * Add doc on test_update_video_owner --------- Co-authored-by: pampletousse commit 033f7ae44287de60755e7d7bf97d87254997138a Author: github-actions Date: Thu Feb 15 09:24:39 2024 +0000 Auto-update configuration files commit d5887810ba4b7edba9a1a0fd94ea07fa03e3cca4 Author: github-actions Date: Thu Feb 15 09:24:17 2024 +0000 Fixup. Format code with Black commit 8770413309bd41eb7ad8546f3c9e8c82b936a84a Author: github-actions Date: Thu Feb 15 09:24:08 2024 +0000 Fixup. Format code with Prettier commit 2d982a819bf2117b901167eeeba72e323bb4f949 Author: Olivier Bado-Faustin Date: Thu Feb 15 10:23:39 2024 +0100 [DONE] Fix dressing Watermark path (#1048) * * Replace `settings.BASE_DIR` by watermark.file.path for video dressing watermark * Some code formatting & docs * Send email to admin on encoding error * * Setup of Video & Audio encoding test case is only called once (This testCase will be 4x faster) * another try to run setup only once * Fix small bug * correct typo * small correction * modify encoding test * * Polish `change video owner` template * move every "mandatory fields" help messagre in a main template to be included * Small change on encoding error handling * Minor QoC * correct typos * Corrections after peer review: * Use django url block * Use {{ owner }} and {{ user }} default str * Various QoC * Add missing parameter types * Specify types of some lists in functions hints * Remove every list content type hint, as they appear only in python >= 3.9 * compilemessages * make lang * test reset lang * + nl * [DONE] Fix dressing Watermark path (#1048) * * Replace `settings.BASE_DIR` by watermark.file.path for video dressing watermark * Some code formatting & docs * Send email to admin on encoding error * * Setup of Video & Audio encoding test case is only called once (This testCase will be 4x faster) * another try to run setup only once * Fix small bug * correct typo * small correction * modify encoding test * * Polish `change video owner` template * move every "mandatory fields" help messagre in a main template to be included * Small change on encoding error handling * Minor QoC * correct typos * Corrections after peer review: * Use django url block * Use {{ owner }} and {{ user }} default str * Various QoC * Add missing parameter types * Specify types of some lists in functions hints * Remove every list content type hint, as they appear only in python >= 3.9 * [DONE] Pampletousse/fix improve bulk update tests (#1053) * Change default sort dashboard videos title into date_added * Change title to type test and add owner test * Change get videos objects * Change values for owner test * Change user for post request * Add test_bulk_update_tags and fix owner bulk update (missing move file) * Fix pep8 * Try to repair test_update_video_owner * Change order of tests + add bulk_delete test * Try to repair bulk_delete test * Try to repair bulk_delete test with user3 videos * add response check of bulk_delete failure * Fix bad value sending to change owner * Fix again test_views not working * Add doc on test_update_video_owner --------- Co-authored-by: pampletousse * [DONE] Ptitloup/feature add video token (#1052) * add video token models, add it in admin, add view to manage token, call it from edit and views template * add access to video with token, improve token list display and add translation * fix indent * add uniq constraints for video access token, add unit test for video access token model, fix translate * add unit test for view, complete unit test for model, improve access token view * add copy to clipboard icon, add video access test view * add js to copy link and embed value * change col size and align middle * improve indent, add translation and pydoc * add info to delete token * Fixup. Format code with Black * fixe lang * little fixes * [DONE] Fix dressing Watermark path (#1048) * * Replace `settings.BASE_DIR` by watermark.file.path for video dressing watermark * Some code formatting & docs * Send email to admin on encoding error * * Setup of Video & Audio encoding test case is only called once (This testCase will be 4x faster) * another try to run setup only once * Fix small bug * correct typo * small correction * modify encoding test * * Polish `change video owner` template * move every "mandatory fields" help messagre in a main template to be included * Small change on encoding error handling * Minor QoC * correct typos * Corrections after peer review: * Use django url block * Use {{ owner }} and {{ user }} default str * Various QoC * Add missing parameter types * Specify types of some lists in functions hints * Remove every list content type hint, as they appear only in python >= 3.9 * first correction after the first review * [DONE] Fix dressing Watermark path (#1048) * * Replace `settings.BASE_DIR` by watermark.file.path for video dressing watermark * Some code formatting & docs * Send email to admin on encoding error * * Setup of Video & Audio encoding test case is only called once (This testCase will be 4x faster) * another try to run setup only once * Fix small bug * correct typo * small correction * modify encoding test * * Polish `change video owner` template * move every "mandatory fields" help messagre in a main template to be included * Small change on encoding error handling * Minor QoC * correct typos * Corrections after peer review: * Use django url block * Use {{ owner }} and {{ user }} default str * Various QoC * Add missing parameter types * Specify types of some lists in functions hints * Remove every list content type hint, as they appear only in python >= 3.9 * make lang + compilemessages * Squashed commit of the following: commit 793d1d033a0647754f2b0e903de64286f36b9f33 Author: github-actions Date: Thu Feb 15 13:29:30 2024 +0000 Fixup. Format code with Black commit 6fd67344d7024396f7427044900884cff0ae5a9b Author: Ptitloup Date: Thu Feb 15 14:28:59 2024 +0100 [DONE] Ptitloup/feature add video token (#1052) * add video token models, add it in admin, add view to manage token, call it from edit and views template * add access to video with token, improve token list display and add translation * fix indent * add uniq constraints for video access token, add unit test for video access token model, fix translate * add unit test for view, complete unit test for model, improve access token view * add copy to clipboard icon, add video access test view * add js to copy link and embed value * change col size and align middle * improve indent, add translation and pydoc * add info to delete token commit c104e93a24d60758d8ef83b798a4c96f9f0078b8 Author: github-actions Date: Thu Feb 15 10:14:37 2024 +0000 Fixup. Format code with Black commit ac8bf7e3d3c92101e913cb00f6721ba160987760 Author: pampletousse <51699553+pampletousse@users.noreply.github.com> Date: Thu Feb 15 11:14:10 2024 +0100 [DONE] Pampletousse/fix improve bulk update tests (#1053) * Change default sort dashboard videos title into date_added * Change title to type test and add owner test * Change get videos objects * Change values for owner test * Change user for post request * Add test_bulk_update_tags and fix owner bulk update (missing move file) * Fix pep8 * Try to repair test_update_video_owner * Change order of tests + add bulk_delete test * Try to repair bulk_delete test * Try to repair bulk_delete test with user3 videos * add response check of bulk_delete failure * Fix bad value sending to change owner * Fix again test_views not working * Add doc on test_update_video_owner --------- Co-authored-by: pampletousse commit 033f7ae44287de60755e7d7bf97d87254997138a Author: github-actions Date: Thu Feb 15 09:24:39 2024 +0000 Auto-update configuration files commit d5887810ba4b7edba9a1a0fd94ea07fa03e3cca4 Author: github-actions Date: Thu Feb 15 09:24:17 2024 +0000 Fixup. Format code with Black commit 8770413309bd41eb7ad8546f3c9e8c82b936a84a Author: github-actions Date: Thu Feb 15 09:24:08 2024 +0000 Fixup. Format code with Prettier commit 2d982a819bf2117b901167eeeba72e323bb4f949 Author: Olivier Bado-Faustin Date: Thu Feb 15 10:23:39 2024 +0100 [DONE] Fix dressing Watermark path (#1048) * * Replace `settings.BASE_DIR` by watermark.file.path for video dressing watermark * Some code formatting & docs * Send email to admin on encoding error * * Setup of Video & Audio encoding test case is only called once (This testCase will be 4x faster) * another try to run setup only once * Fix small bug * correct typo * small correction * modify encoding test * * Polish `change video owner` template * move every "mandatory fields" help messagre in a main template to be included * Small change on encoding error handling * Minor QoC * correct typos * Corrections after peer review: * Use django url block * Use {{ owner }} and {{ user }} default str * Various QoC * Add missing parameter types * Specify types of some lists in functions hints * Remove every list content type hint, as they appear only in python >= 3.9 * test reset lang * fix last conflicts * make lang * make lang * add visible field * fix default block * fix flake * remove test events_next.html * bugfix after review * flake8 and unit test * bugfix unit test * add unit test and bugfix * add unit test main views * add unit test main views * remove tempate last videos and some variables * update after review * fix error block template * fix after review * fix card_list and lang * pydoc fix and translation --------- Co-authored-by: Valentin Sabatier Co-authored-by: Olivier Bado-Faustin Co-authored-by: github-actions Co-authored-by: pampletousse <51699553+pampletousse@users.noreply.github.com> Co-authored-by: pampletousse Co-authored-by: Ptitloup Co-authored-by: Valentin Sabatier <62725910+vsabatie@users.noreply.github.com> Co-authored-by: Charneau Franck --- pod/live/tests/test_views.py | 4 +- pod/locale/fr/LC_MESSAGES/django.mo | Bin 184544 -> 187330 bytes pod/locale/fr/LC_MESSAGES/django.po | 213 ++++++++-- pod/locale/fr/LC_MESSAGES/djangojs.mo | Bin 20439 -> 19745 bytes pod/locale/fr/LC_MESSAGES/djangojs.po | 102 ++--- pod/locale/nl/LC_MESSAGES/django.po | 223 ++++++++-- pod/locale/nl/LC_MESSAGES/djangojs.po | 69 +-- pod/main/admin.py | 53 ++- pod/main/apps.py | 38 ++ pod/main/configuration.json | 10 +- pod/main/context_processors.py | 34 +- pod/main/fixtures/first_block.json | 18 + pod/main/models.py | 177 ++++++++ pod/main/settings.py | 1 - pod/main/static/css/block.css | 155 +++++++ pod/main/static/js/admin.js | 127 ++++++ pod/main/templates/admin/base_site.html | 5 + pod/main/templates/base.html | 10 +- pod/main/templates/block/card_list.html | 24 ++ pod/main/templates/block/carousel.html | 99 +++++ pod/main/templates/block/html.html | 3 + pod/main/templates/block/multi_carousel.html | 109 +++++ pod/main/templates/flatpages/default.html | 10 + .../templatetags/flat_page_edito_filter.py | 392 ++++++++++++++++++ pod/main/test_settings.py | 1 - pod/main/tests/test_models.py | 20 +- pod/main/tests/test_views.py | 233 ++++++++++- pod/main/translation.py | 22 + pod/settings.py | 1 + pod/video/templates/videos/last_videos.html | 17 - pod/video/templatetags/video_tags.py | 37 -- 31 files changed, 1927 insertions(+), 280 deletions(-) create mode 100644 pod/main/fixtures/first_block.json create mode 100644 pod/main/static/css/block.css create mode 100644 pod/main/static/js/admin.js create mode 100644 pod/main/templates/block/card_list.html create mode 100644 pod/main/templates/block/carousel.html create mode 100644 pod/main/templates/block/html.html create mode 100644 pod/main/templates/block/multi_carousel.html create mode 100644 pod/main/templatetags/flat_page_edito_filter.py delete mode 100644 pod/video/templates/videos/last_videos.html diff --git a/pod/live/tests/test_views.py b/pod/live/tests/test_views.py index 27fb990e46..3fb19e5e99 100644 --- a/pod/live/tests/test_views.py +++ b/pod/live/tests/test_views.py @@ -789,8 +789,8 @@ def test_events(self): access_group = AccessGroup.objects.get(code_name="group1") # User not logged in response = self.client.get("/") - self.assertTemplateUsed(response, "live/events_next.html") - print(" ---> test_events of `/`: OK!") + # self.assertTemplateUsed(response, "live/events_next.html") + # print(" ---> test_events of `/`: OK!") response = self.client.get("/live/events/") self.assertTemplateUsed(response, "live/events.html") diff --git a/pod/locale/fr/LC_MESSAGES/django.mo b/pod/locale/fr/LC_MESSAGES/django.mo index da947572a69fa276ae9d1f3744c062ce26d4adf8..083cdba51080c0315202708bf405f61d9ae6b3f8 100644 GIT binary patch delta 43760 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 delta 41657 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 Ah5!Hn diff --git a/pod/locale/fr/LC_MESSAGES/django.po b/pod/locale/fr/LC_MESSAGES/django.po index ad78e4fe9e..30896fff58 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-03 15:23+0000\n" +"POT-Creation-Date: 2024-04-10 14:22+0000\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -14,7 +14,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -"X-Generator: Poedit 3.4\n" +"X-Generator: Poedit 3.4.2\n" #: pod/authentication/admin.py pod/main/forms.py msgid "Email" @@ -2269,7 +2269,7 @@ msgstr "Temps de fin de l’affichage de l’enrichissement en secondes." #: pod/enrichment/models.py #: pod/enrichment/templates/enrichment/list_enrichment.html #: pod/import_video/templates/import_video/list.html pod/live/models.py -#: pod/video/forms.py pod/video/models.py +#: pod/main/models.py pod/video/forms.py pod/video/models.py #: pod/video/templates/videos/dashboard.html pod/video/views.py #: pod/video_search/templates/search/search.html msgid "Type" @@ -3494,6 +3494,7 @@ msgid "Plan an event" msgstr "Programmer un évènement" #: pod/live/templates/live/direct.html pod/live/templates/live/events_next.html +#: pod/main/models.py pod/main/templatetags/flat_page_edito_filter.py msgid "Next events" msgstr "Prochains évènements" @@ -4828,6 +4829,157 @@ msgstr "Onglet additionnel de chaînes" msgid "Additional channel Tabs" msgstr "Onglets additionnels de chaînes" +#: pod/main/models.py +msgid "Carousel" +msgstr "Carrousel" + +#: pod/main/models.py +msgid "Multiple carousel" +msgstr "Carrousel multiple" + +#: pod/main/models.py +msgid "Card list" +msgstr "Liste de cartes" + +#: pod/main/models.py +msgid "HTML" +msgstr "HTML" + +#: pod/main/models.py pod/video/models.py +#: pod/video_search/templates/search/search.html +msgid "Channel" +msgstr "Chaîne" + +#: pod/main/models.py pod/video/models.py +msgid "Theme" +msgstr "Thème" + +#: pod/main/models.py pod/playlist/models.py +#: pod/playlist/templates/playlist/playlist_breadcrumbs.html +#: pod/playlist/tests/test_models.py pod/playlist/views.py +#: pod/video/templates/videos/video_breadcrumbs.html +msgid "Playlist" +msgstr "Liste de lecture" + +#: pod/main/models.py pod/main/templatetags/flat_page_edito_filter.py +msgid "Last videos" +msgstr "Dernières vidéos" + +#: pod/main/models.py pod/main/templatetags/flat_page_edito_filter.py +msgid "Most views" +msgstr "Les plus vues" + +#: pod/main/models.py +msgid "Order" +msgstr "ordre" + +#: pod/main/models.py pod/video/models.py +msgid "Visible" +msgstr "Visible" + +#: pod/main/models.py +msgid "Check this box if block is visible in page." +msgstr "Cocher cette case si vous voulez voir le bloc sur la page." + +#: pod/main/models.py +msgid "Data type" +msgstr "Type de données" + +#: pod/main/models.py +msgid "Select the channel you want to link with." +msgstr "Sélectionner la chaîne que vous voulez joindre." + +#: pod/main/models.py +msgid "Select the theme you want to link with." +msgstr "Sélectionner le thème que vous voulez joindre." + +#: pod/main/models.py +msgid "Select the playlist you want to link with." +msgstr "Sélectionner la liste de lecture que vous voulez joindre." + +#: pod/main/models.py +msgid "Write in html inside this field." +msgstr "Écrire en html à l'intérieur de ce champ." + +#: pod/main/models.py +msgid "Display title" +msgstr "Afficher le titre" + +#: pod/main/models.py +msgid "No cache" +msgstr "Pas de cache" + +#: pod/main/models.py +msgid "Check this box if you don't want to keep the cache." +msgstr "Cocher cette case si vous ne voulez pas garder le cache." + +#: pod/main/models.py +msgid "Debug" +msgstr "Debug" + +#: pod/main/models.py +msgid "Check this box if you want to activate debug mode." +msgstr "Cocher cette case si vous voulez activer le mode débogage." + +#: pod/main/models.py +msgid "Show restricted content" +msgstr "Montrer le contenu restreint" + +#: pod/main/models.py +msgid "Check this box if you want to show restricted content." +msgstr "Cocher cette case si vous voulez montrer le contenu restreint." + +#: pod/main/models.py +msgid "Must be authenticated" +msgstr "Doit être authentifié" + +#: pod/main/models.py +msgid "Check this box if users must be authenticated to view content." +msgstr "" +"Cochez cette case si les utilisateurs doivent être authentifiés pour voir le " +"contenu." + +#: pod/main/models.py +msgid "Auto slide" +msgstr "Défilement automatique" + +#: pod/main/models.py +msgid "Check this box if you want auto slide." +msgstr "Cocher cette case si vous voulez activer le défilement automatique." + +#: pod/main/models.py +msgid "Maximum number of element" +msgstr "Nombre d'éléments maximum" + +#: pod/main/models.py +msgid "Number of element per page (multi carousel)" +msgstr "Nombre d'éléments par page (carrousel multiple)" + +#: pod/main/models.py +msgid "View videos from non visible channel" +msgstr "Voir les vidéos d'une chaine non visible" + +#: pod/main/models.py +msgid "Check this box if you want view videos from non visible channel." +msgstr "" +"Cocher cette case si vous voulez voir les vidéos d'une chaine non visible." + +#: pod/main/models.py +msgid "View videos with password" +msgstr "Voir les vidéos avec un mot de passe" + +#: pod/main/models.py +msgid "Check this box if you want view videos with password." +msgstr "Cocher cette case si vous voulez voir les vidéos avec un mot de passe." + +#: pod/main/models.py +msgid "Block" +msgstr "Bloc" + +#: pod/main/models.py +msgid "Blocks" +msgstr "Blocs" + #: pod/main/templates/403.html msgid "Permission denied" msgstr "Accès refusé" @@ -4911,6 +5063,27 @@ msgstr "En savoir plus" msgid "I understand" msgstr "J’ai compris" +#: pod/main/templates/block/card_list.html +#: pod/main/templates/block/carousel.html +#: pod/main/templates/block/multi_carousel.html +msgid "Show all videos" +msgstr "Afficher toutes les vidéos" + +#: pod/main/templates/block/carousel.html +#, python-format +msgid "Video %(video_number)s" +msgstr "Vidéo %(video_number)s" + +#: pod/main/templates/block/carousel.html +#: pod/main/templates/block/multi_carousel.html +msgid "Previous thumbnail" +msgstr "Vignette précédente" + +#: pod/main/templates/block/carousel.html +#: pod/main/templates/block/multi_carousel.html +msgid "Next thumbnail" +msgstr "Vignette suivante" + #: pod/main/templates/contact_us.html msgid "Your message" msgstr "Votre message" @@ -6194,13 +6367,6 @@ msgstr "Protégé par un mot de passe" msgid "Private" msgstr "Privé" -#: pod/playlist/models.py -#: pod/playlist/templates/playlist/playlist_breadcrumbs.html -#: pod/playlist/tests/test_models.py pod/playlist/views.py -#: pod/video/templates/videos/video_breadcrumbs.html -msgid "Playlist" -msgstr "Liste de lecture" - #: pod/playlist/models.py msgid "" "Selecting this setting causes your playlist to be promoted on the page " @@ -7700,10 +7866,6 @@ msgstr "" msgid "The style will be added to your channel to show it" msgstr "Le style sera ajouté à votre chaîne" -#: pod/video/models.py -msgid "Visible" -msgstr "Visible" - #: pod/video/models.py msgid "" "If checked, the channel appear in a list of available channels on the " @@ -7721,10 +7883,6 @@ msgstr "" msgid "Additionals channels tab" msgstr "Onglet additionnel de chaînes" -#: pod/video/models.py pod/video_search/templates/search/search.html -msgid "Channel" -msgstr "Chaîne" - #: pod/video/models.py msgid "Theme parent" msgstr "Thème parent" @@ -7742,10 +7900,6 @@ msgstr "" msgid "A theme must be in the same channel as its parent." msgstr "Un thème doit se trouver dans la même chaîne que son parent." -#: pod/video/models.py -msgid "Theme" -msgstr "Thème" - #: pod/video/models.py msgid "Icon" msgstr "Icone" @@ -8367,14 +8521,6 @@ msgstr "Éditer la catégorie" msgid "Links" msgstr "Liens" -#: pod/video/templates/videos/last_videos.html -msgid "Last videos" -msgstr "Dernières vidéos" - -#: pod/video/templates/videos/last_videos.html -msgid "Show all videos" -msgstr "Afficher toutes les vidéos" - #: pod/video/templates/videos/link_video.html #: pod/video/templates/videos/video_row_select.html msgid "Remove from playlist" @@ -9389,6 +9535,12 @@ msgstr "Résultats de la recherche" msgid "Esup-Pod xAPI" msgstr "xAPI Esup-Pod" +#~ msgid "Previous" +#~ msgstr "Vignette précédente" + +#~ msgid "Next" +#~ msgstr "Vignette suivante" + #~ msgid "" #~ "Pod is aimed at users of our institutions, by allowing the publication of " #~ "videos in the fields of research (promotion of platforms, etc.), training " @@ -9403,7 +9555,6 @@ msgstr "xAPI Esup-Pod" #~ msgstr "" #~ "Le fichier HTML de cet enregistrement est introuvable sur le serveur." -#, python-format #~ msgid "Error number: %s" #~ msgstr "Numéro d’erreur : %s" diff --git a/pod/locale/fr/LC_MESSAGES/djangojs.mo b/pod/locale/fr/LC_MESSAGES/djangojs.mo index fecebaff64b33238a97e2516273e8f0b824fd608..4036a1ad3eb9460cd8488edf62d3fd87e069a1df 100644 GIT binary patch delta 4450 zcmYk+HtYV%Wb_h9>R#uaZS^qdzGv?T3MfU#e^ElHpeD?Kwp56UDzw=rBs?M`@v&VO) zUD(sc(M*!b{BW}vkJ+JUT{Ziqjae98#0>l$y_nS2tQY2CU#!5HSc@I71uHP2o!J0f z=DHXAaQ-t6G4okUyjcntZoyD|0J(=f!L z!ba?jXOX_y-xz@*?ai{ezeQ0{3v*E|tHkSYEB3$>s0#naZ0t^JVsVOVIdk%r+>iQx z6Kdp6xYxf&+H4n5J&vH#5{$!cSdKp3poW5aREO$e12RhXEow?GqDJC3)D69z%&x)S z*d6nbx2zm>!)2(BY(%Z89jFog5YzBi)QEKG%=l}F1~Wk#`Z82S>#-Pjpj!GDY7ur# za&FuW)sX8^9T<*LSb_s_CQigns0RFmTD*~*XbQ4X-z!RH{B^@JE@~qN%D!P1P5uAwG|~aiZ7hSROLkR^nQTs(&qNP5A05Xz?_l z=4>x&8#SSZ{4b2bE2xo*p?g}4nW&!T@S>g-qdHQJdUVvf?nKqoh*}#5T~8t%@Yy*E zYI%FcsV9y=&E?&w)xHGv{tDEhdjoZYL+*JKYH^-I^;|FNKn7|pl(^?Lu1CFXF;jT!M#?^=7$rr!P*zA-EdH;QKfdugP>)e=%~&9z=a_3u?#@BKNRUs42RP z>Od^>*a|yiCige53OE`y#0ybFyax5)c@h~UJB&=PeTT7l#Wl9OGj&~1L)-^-!_hbl zr=y;Pb*S$*VShZ2KJ_q_cL(7_timUdx-EopRfRoJ4J*LbIL&nqs^JST0ynzXYf+!C zNA>s}_xdNOsXK+;JZ3-iVEnZ>qUa{0ZgI#oT0c|^r(z-2;7t4!*%;QFm9q{fpx!@& z`RHY#496Kb4Y#3s{12*u$?Rcm+YHoVACklPtHKFf&>Szs5x5J-;|1jDY5Ahzv1iwHnzCTbsPwMCNI2{>%8|a?Zj$t1%pxV*;+h zwpfdLzX9X%ZB)aKqZ)P^)$_|Z3q$CQZZH?s(DkT#UPevTKGg2GjG@~9@r*|<7dm1B z-i*;$j#|BoP(9jAI2-3-f9?M! z3R;9$P^&eJTH9e8R7L670rRmGC!?Mdub~=}#YC%t)6k1cQJ;SSwLA7;M?8&c&?RJ6 zEuIB;9rw3U6bi5kv#=4h{m!B)h|F^qT@EsxR*AatOQ?!IM>c?kvjrAlKU6*2kSgpr zYAr;vz|yc6s{WhM$9q;yfj`^Bi?-n`+T7 zaMnZ-YVl1*eQ!E4$X10K>4pNvUqf|(3u@U()MC4YdZf0X8kAn>EUGc6#dI&~d#g|t z)nXd%Krc2UZLyz_KkGTn8Hoj`-L(_7*!KM8W`I+nttI0Zk$shBm| z`4wA%>@0f=JK;G@Mh{C`4NAdy9E)mj8S2NS0`{sA}mAJqdkrVcnldFYhA<^#__28h4N*u z_J3~*x^XeK#``e}SE7cn1|#rYjK*WAo}5B8@FHqXJ>#8*#i57u0-}cNP{X?t?HV;g zho;r+*5GS!XjNj`gU3!@LrD#}DR{-Hd^+WM(Q%24J;*_bi$!Ij`FXFiT9~X zj}z^m+eivoK|Uru$sVGWp{`s#-lS0Bp7g?M!o1r?_nH_>>fG}ev6d_%cM=Vcjtv^$ z2Po_!+AlgLkRHJj|3z`V%Ci)-dM1-s-HV#cFfxtIBCClW8n2La@)Frjl8KJ)4z@U` z&pFcs&voD6C)1uHJIEly)(Gx}`|%!9Oqf%fLA0{8e`XUM4diyBYS)vaoK*J93qM238G^NSx&U%|9{kTvWk?r7w7-~P0II?Lc(gcEkwsMk`XL9 z|MHwe*>`}KGP0W_5FMQzg1@6~(LSQkNYY3p8Atr&ZlZ%9jo{J8RXk4~Bd?M7$r3V= ztRpK)j0WmG3h$8iL`O6^M%X67jrlM}lFdX%u7ll$&ycC^c@rKcOUZ}iN%9EEB5#nZ z$GvW$FHX@!1t`2t-X%J=k=IFo(vLhy?jUcGeI%af7(<%L0&)v!LqbS4(eZ(U-HdC= zweIG#JxQrpve_|50O=e+ms z@_&=;*c|f7wh;ekvEeTm+Q%f3oQyCgCB&HW7@eiX8WRp7*cT(vfoV7hr{FMLiVJZs zcEy-DV_eu5vv7m$DICi2T^wbM-(++)rWYp`V;HW%6kLz|nE)Rh@g3Xqs0aKR`(Q|y z-~du^1jlo*6F!GSa2INz=WqaqaB%{rVFb@N6X>+%#8gbj*_ee-qDJ}_X5e3OAohq4 z-f#ly`_ReV4Tg6QJ}?S9aNHAhzjV~~nWz<=gnsS$Tsr#V5@eN3Eow!! z;B0&u`{P%r2PU(AnsFv-1;!z}VID#qmK^$3n^ z0BS&^QA<7#HM2FSmDz||x&x@C{5@)m?x1d%&cy{d0X5)yRPi3imI0s!bQU#{KPIyN zQFLx`A{)QPnV7|X&^2pNRlggxMa_KZ!W*au-a##8XDV3VPeB!L9_qR>9E|Jje{V#} z$Xr0x#6SFWG=uQo!5LQ$%*zloaJ1!TL;E!!AIqX+ay)kZ$5sHUKbst{FtrKlCD zKn-98Y76#b3?4w;{{*tNeshry&oG~(W*WgLw1jD>3ky)y|DbKLeZC4+J3iaBsG03R zP3V10!>>_WJdm4ffWuKWH3~EI{^!$Caa5xoa)bR}4 zmFVF34IF_Vp$DV;8M6%iNa341I1Ib8FQa(AnM`LAK8@q?45ngdKDdV&jk>THwIwT1 z1FA>u;Zf8C&SD$<6nTcZY8%DRo|b$lM&kWA2n*4#rLUo*;@FAp@r3PJ)ZSi3E%iT9 z4~$}2@~}5@$`qllUx&kSFKUKYa3pqNAAC3+nVUIs2zJYSh?JMPjT&GA1(J(1GFbnGbT)E= zH__a}4H(Nyt1s-p(RdZdVGyP5WkBHa01rb$CogR|Ss2OEr7aWUhmzj+!&XuT{zl~bb>!@Ok;%T}r17k4@JKW2E3s0g3 zbPFe7FFvSCV__j~Lv5Y^79A~7|B1mRo`o-Pyb6zCPfArw`3dq>a~mh)7(O1v)%Xxz zLVo;A_Wf)dmZ7$4D<N+oKD{7EGvyl(Y{1T??{g0b!3~z|Z#T;CLY@<1b2Qh3~ z@PS9r!SOZJgWFCI)-z}F?a|&X=zT;3BlduW766h z>T+Y^{9gMo1s}2d$++C^3zgWf$ZvH(n@tMHQPN1X4sFN=!n&JEqD>t@G(@$zL0UFb zU6#j$YJ>PyYH0)c{IT6<*;>}@EV3MCD|wZ8NHI~7;^eBfO9Q)t7-WRPdcPSV=C(^=YjfQPhnYQH2-GMyxma%t{a)qI}n?2BqF-1e5sjf@#*lo>yA4<3#}}vdnxH=NK&9C zd0+JSRBM2vys4qosw}YvSG#@H>CC{x45~a$ukjSC+~-O!cA4D8ZlC7lt8ATs)5X_< za|-ks9N8wZ#BwZh6_>dzk3A!gqvT4>i%hH3s!Pt?mCwX@lH235Orfu;%HyiwhUHF8 zuBpL&&$(hN)$9HL0}C`~UJPx=G%a7tJTGU*2aaYRiR?PFsiDeUS>mzGw>^i`7bwe# g4vDB-;$CWb0-oHdA?\n" "Language-Team: \n" @@ -179,19 +179,11 @@ 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" @@ -546,14 +538,6 @@ 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" @@ -676,37 +660,14 @@ 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" @@ -719,6 +680,13 @@ 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." @@ -751,47 +719,38 @@ 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" @@ -864,18 +823,6 @@ 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" @@ -957,3 +904,36 @@ 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 1f3a7216d1..d8b390666b 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-03 15:23+0000\n" +"POT-Creation-Date: 2024-04-10 14:22+0000\n" "PO-Revision-Date: 2023-06-08 14:37+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -2164,7 +2164,7 @@ msgstr "" #: pod/enrichment/models.py #: pod/enrichment/templates/enrichment/list_enrichment.html #: pod/import_video/templates/import_video/list.html pod/live/models.py -#: pod/video/forms.py pod/video/models.py +#: pod/main/models.py pod/video/forms.py pod/video/models.py #: pod/video/templates/videos/dashboard.html pod/video/views.py #: pod/video_search/templates/search/search.html msgid "Type" @@ -2793,15 +2793,15 @@ 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 "" #: 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 "" #: pod/import_video/views.py @@ -3265,6 +3265,7 @@ msgid "Plan an event" msgstr "" #: pod/live/templates/live/direct.html pod/live/templates/live/events_next.html +#: pod/main/models.py pod/main/templatetags/flat_page_edito_filter.py msgid "Next events" msgstr "" @@ -4569,6 +4570,156 @@ msgstr "" msgid "Additional channel Tabs" msgstr "" +#: pod/main/models.py +msgid "Carousel" +msgstr "" + +#: pod/main/models.py +msgid "Multiple carousel" +msgstr "" + +#: pod/main/models.py +msgid "Card list" +msgstr "" + +#: pod/main/models.py +msgid "HTML" +msgstr "" + +#: pod/main/models.py pod/video/models.py +#: pod/video_search/templates/search/search.html +msgid "Channel" +msgstr "" + +#: pod/main/models.py pod/video/models.py +msgid "Theme" +msgstr "" + +#: pod/main/models.py pod/playlist/models.py +#: pod/playlist/templates/playlist/playlist_breadcrumbs.html +#: pod/playlist/tests/test_models.py pod/playlist/views.py +#: pod/video/templates/videos/video_breadcrumbs.html +msgid "Playlist" +msgstr "" + +#: pod/main/models.py pod/main/templatetags/flat_page_edito_filter.py +msgid "Last videos" +msgstr "" + +#: pod/main/models.py pod/main/templatetags/flat_page_edito_filter.py +msgid "Most views" +msgstr "" + +#: pod/main/models.py +msgid "Order" +msgstr "" + +#: pod/main/models.py pod/video/models.py +msgid "Visible" +msgstr "" + +#: pod/main/models.py +msgid "Check this box if block is visible in page." +msgstr "" + +#: pod/main/models.py +msgid "Data type" +msgstr "" + +#: pod/main/models.py +msgid "Select the channel you want to link with." +msgstr "" + +#: pod/main/models.py +msgid "Select the theme you want to link with." +msgstr "" + +#: pod/main/models.py +msgid "Select the playlist you want to link with." +msgstr "" + +#: pod/main/models.py +msgid "Write in html inside this field." +msgstr "" + +#: pod/main/models.py +#, fuzzy +#| msgid "Filter by title" +msgid "Display title" +msgstr "Filter op titel" + +#: pod/main/models.py +msgid "No cache" +msgstr "" + +#: pod/main/models.py +msgid "Check this box if you don't want to keep the cache." +msgstr "" + +#: pod/main/models.py +msgid "Debug" +msgstr "" + +#: pod/main/models.py +msgid "Check this box if you want to activate debug mode." +msgstr "" + +#: pod/main/models.py +msgid "Show restricted content" +msgstr "" + +#: pod/main/models.py +msgid "Check this box if you want to show restricted content." +msgstr "" + +#: pod/main/models.py +msgid "Must be authenticated" +msgstr "" + +#: pod/main/models.py +msgid "Check this box if users must be authenticated to view content." +msgstr "" + +#: pod/main/models.py +msgid "Auto slide" +msgstr "" + +#: pod/main/models.py +msgid "Check this box if you want auto slide." +msgstr "" + +#: pod/main/models.py +msgid "Maximum number of element" +msgstr "" + +#: pod/main/models.py +msgid "Number of element per page (multi carousel)" +msgstr "" + +#: pod/main/models.py +msgid "View videos from non visible channel" +msgstr "" + +#: pod/main/models.py +msgid "Check this box if you want view videos from non visible channel." +msgstr "" + +#: pod/main/models.py +msgid "View videos with password" +msgstr "" + +#: pod/main/models.py +msgid "Check this box if you want view videos with password." +msgstr "" + +#: pod/main/models.py +msgid "Block" +msgstr "" + +#: pod/main/models.py +msgid "Blocks" +msgstr "" + #: pod/main/templates/403.html msgid "Permission denied" msgstr "" @@ -4647,6 +4798,27 @@ msgstr "" msgid "I understand" msgstr "" +#: pod/main/templates/block/card_list.html +#: pod/main/templates/block/carousel.html +#: pod/main/templates/block/multi_carousel.html +msgid "Show all videos" +msgstr "" + +#: pod/main/templates/block/carousel.html +#, python-format +msgid "Video %(video_number)s" +msgstr "" + +#: pod/main/templates/block/carousel.html +#: pod/main/templates/block/multi_carousel.html +msgid "Previous thumbnail" +msgstr "" + +#: pod/main/templates/block/carousel.html +#: pod/main/templates/block/multi_carousel.html +msgid "Next thumbnail" +msgstr "" + #: pod/main/templates/contact_us.html msgid "Your message" msgstr "" @@ -5680,8 +5852,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)sHello,

\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" @@ -5818,13 +5990,6 @@ msgstr "" msgid "Private" msgstr "" -#: pod/playlist/models.py -#: pod/playlist/templates/playlist/playlist_breadcrumbs.html -#: pod/playlist/tests/test_models.py pod/playlist/views.py -#: pod/video/templates/videos/video_breadcrumbs.html -msgid "Playlist" -msgstr "" - #: pod/playlist/models.py msgid "" "Selecting this setting causes your playlist to be promoted on the page " @@ -6689,8 +6854,8 @@ msgstr "" #: 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 "" #: pod/recorder/templates/recorder/link_record.html @@ -7186,10 +7351,6 @@ msgstr "" msgid "The style will be added to your channel to show it" msgstr "" -#: pod/video/models.py -msgid "Visible" -msgstr "" - #: pod/video/models.py msgid "" "If checked, the channel appear in a list of available channels on the " @@ -7204,10 +7365,6 @@ msgstr "" msgid "Additionals channels tab" msgstr "" -#: pod/video/models.py pod/video_search/templates/search/search.html -msgid "Channel" -msgstr "" - #: pod/video/models.py msgid "Theme parent" msgstr "" @@ -7224,10 +7381,6 @@ msgstr "" msgid "A theme must be in the same channel as its parent." msgstr "" -#: pod/video/models.py -msgid "Theme" -msgstr "" - #: pod/video/models.py msgid "Icon" msgstr "" @@ -7822,14 +7975,6 @@ msgstr "" msgid "Links" msgstr "" -#: pod/video/templates/videos/last_videos.html -msgid "Last videos" -msgstr "" - -#: pod/video/templates/videos/last_videos.html -msgid "Show all videos" -msgstr "" - #: pod/video/templates/videos/link_video.html #: pod/video/templates/videos/video_row_select.html msgid "Remove from playlist" diff --git a/pod/locale/nl/LC_MESSAGES/djangojs.po b/pod/locale/nl/LC_MESSAGES/djangojs.po index 781dc2b728..5523a0a011 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-03-12 12:01+0100\n" +"POT-Creation-Date: 2024-04-10 14:22+0000\n" "PO-Revision-Date: 2023-02-08 15:22+0100\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -163,19 +163,11 @@ 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" @@ -520,14 +512,6 @@ 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 "" @@ -645,37 +629,14 @@ 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 "" @@ -688,6 +649,13 @@ 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 "" @@ -720,47 +688,38 @@ 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 "" @@ -832,18 +791,6 @@ 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/admin.py b/pod/main/admin.py index 326afcacad..9c09f24cb2 100644 --- a/pod/main/admin.py +++ b/pod/main/admin.py @@ -1,5 +1,8 @@ +"""Esup-Pod main admin page.""" + from ckeditor.widgets import CKEditorWidget from django.contrib import admin +from django import forms from django.contrib.flatpages.admin import FlatpageForm from django.contrib.flatpages.models import FlatPage from django.contrib.sites.models import Site @@ -9,6 +12,7 @@ from modeltranslation.admin import TranslationAdmin from pod.main.models import LinkFooter, Configuration from pod.main.models import AdditionalChannelTab +from pod.main.models import Block SITE_ID = getattr(settings, "SITE_ID", 1) @@ -26,10 +30,8 @@ class Meta: widgets = content_widget -# CustomFlatPage admin panel - - -class AdditionalChannelTabAdmin(admin.ModelAdmin): +class AdditionalChannelTabAdmin(TranslationAdmin): + """ Create translation for additional Channel Tab Field""" list_display = ("name",) @@ -101,14 +103,55 @@ def get_queryset(self, request): return qs def formfield_for_foreignkey(self, db_field, request, **kwargs): + """Exclude sites fields in admin for non-superuser""" if (db_field.name) == "page": - kwargs["queryset"] = FlatPage.objects.filter(sites=Site.objects.get_current()) + kwargs["queryset"] = FlatPage.objects.filter( + sites=Site.objects.get_current() + ) return super().formfield_for_foreignkey(db_field, request, **kwargs) +class BlockAdminForm(forms.ModelForm): + """The form for Block administration in the Django admin panel.""" + + class Meta: + """Metadata class defining the associated model and fields.""" + + model = Block + fields = '__all__' + + +class BlockAdmin(TranslationAdmin): + """The admin configuration for the Block model in the Django admin panel.""" + + list_display = ( + "title", + "page", + "type", + "data_type", + ) + + def get_form(self, request, obj=None, **kwargs): + """ + Get the form to be used in the Django admin. + + Args: + request: The Django request object. + obj: The Block object being edited, or None if creating a new one. + **kwargs: Additional keyword arguments. + + Returns: + Type[forms.ModelForm]: The form class to be used in the admin. + """ + form = super().get_form(request, obj, **kwargs) + + return form + + # Unregister the default FlatPage admin and register CustomFlatPageAdmin. admin.site.unregister(FlatPage) admin.site.register(FlatPage, CustomFlatPageAdmin) admin.site.register(LinkFooter, LinkFooterAdmin) admin.site.register(Configuration, ConfigurationAdmin) admin.site.register(AdditionalChannelTab, AdditionalChannelTabAdmin) +admin.site.register(Block, BlockAdmin) diff --git a/pod/main/apps.py b/pod/main/apps.py index a2ba6a5629..88af325554 100644 --- a/pod/main/apps.py +++ b/pod/main/apps.py @@ -108,6 +108,43 @@ def create_missing_conf(sender, **kwargs): print("--> No missing configurations found, all is up to date!") +def create_first_block(sender, **kwargs): + """Create first block from first_block.json.""" + from pod.main.models import Block + from django.contrib.flatpages.models import FlatPage + + print("---> Creating block if not exist...") + json_data = [] + with open("./pod/main/fixtures/first_block.json", encoding="utf-8") as data_file: + json_data = json.loads(data_file.read()) + + count = 0 + for fixture in json_data: + if fixture["model"] == "main.block": + title = fixture["fields"]["title"] + type = fixture["fields"]["type"] + data_type = fixture["fields"]["data_type"] + display_title_en = fixture["fields"]["display_title_en"] + display_title_fr = fixture["fields"]["display_title_fr"] + block_count = Block.objects.all().count() + if block_count > 0: + print("-> block exist...") + else: + print("-> Creating block...") + count = count + 1 + url_homepage = "/" + Block.objects.create( + title=title, + type=type, + data_type=data_type, + page=FlatPage.objects.get(url=url_homepage), + display_title_en=display_title_en, + display_title_fr=display_title_fr, + ) + if count == 0: + print("--> No block add, all is up to date!") + + class MainConfig(AppConfig): """Esup-pod Main configurations class.""" @@ -119,3 +156,4 @@ def ready(self): """Run code when Django starts.""" post_migrate.connect(create_missing_conf, sender=self) post_migrate.connect(create_missing_pages, sender=self) + post_migrate.connect(create_first_block, sender=self) diff --git a/pod/main/configuration.json b/pod/main/configuration.json index 5fd1d395aa..c1ef7c6712 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -1355,7 +1355,7 @@ "Affiche les vidéos de chaines non visibles sur la page d'accueil" ] }, - "pod_version_end": "", + "pod_version_end": "3.6.0", "pod_version_init": "3.2.0" }, "USE_BBB": { @@ -4723,7 +4723,7 @@ "Nombre de vidéos à afficher sur la page d’accueil." ] }, - "pod_version_end": "", + "pod_version_end": "3.6.0", "pod_version_init": "3.1.0" }, "HOMEPAGE_SHOWS_PASSWORDED": { @@ -4736,7 +4736,7 @@ "Afficher les vidéos dont l’accès est protégé par mot de passe sur la page d’accueil." ] }, - "pod_version_end": "", + "pod_version_end": "3.6.0", "pod_version_init": "3.1.0" }, "HOMEPAGE_SHOWS_RESTRICTED": { @@ -4749,7 +4749,7 @@ "Afficher les vidéos dont l’accès est protégé par authentification sur la page d’accueil." ] }, - "pod_version_end": "", + "pod_version_end": "3.6.0", "pod_version_init": "3.1.0" }, "MENUBAR_HIDE_INACTIVE_OWNERS": { @@ -4802,7 +4802,7 @@ "Si True, affiche les prochains évènements sur la page d’accueil." ] }, - "pod_version_end": "", + "pod_version_end": "3.6.0", "pod_version_init": "3.1.0" }, "SHOW_ONLY_PARENT_THEMES": { diff --git a/pod/main/context_processors.py b/pod/main/context_processors.py index ba0616da58..134ab16a03 100644 --- a/pod/main/context_processors.py +++ b/pod/main/context_processors.py @@ -1,7 +1,7 @@ from django.conf import settings as django_settings from django.core.exceptions import ImproperlyConfigured -from pod.main.models import LinkFooter +from pod.main.models import LinkFooter, Block from django.core.exceptions import ObjectDoesNotExist from pod.main.models import Configuration @@ -70,8 +70,6 @@ COOKIE_LEARN_MORE = getattr(django_settings, "COOKIE_LEARN_MORE", "") -SHOW_EVENTS_ON_HOMEPAGE = getattr(django_settings, "SHOW_EVENTS_ON_HOMEPAGE", False) - USE_OPENCAST_STUDIO = getattr(django_settings, "USE_OPENCAST_STUDIO", False) USE_MEETING = getattr(django_settings, "USE_MEETING", False) @@ -137,14 +135,13 @@ def context_settings(request): new_settings["DYSLEXIAMODE_ENABLED"] = DYSLEXIAMODE_ENABLED new_settings["USE_OPENCAST_STUDIO"] = USE_OPENCAST_STUDIO new_settings["COOKIE_LEARN_MORE"] = COOKIE_LEARN_MORE - new_settings["SHOW_EVENTS_ON_HOMEPAGE"] = SHOW_EVENTS_ON_HOMEPAGE new_settings["USE_MEETING"] = USE_MEETING - new_settings["RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY"] = ( - RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY - ) - new_settings["RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY"] = ( - RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY - ) + new_settings[ + "RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY" + ] = RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY + new_settings[ + "RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY" + ] = RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY new_settings["USE_NOTIFICATIONS"] = USE_NOTIFICATIONS return new_settings @@ -154,3 +151,20 @@ def context_footer(request): return { "LINK_FOOTER": linkFooter, } + + +def context_block(request): + """ + Return the context for blocks to be displayed in templates. + + Args: + request (HttpRequest): The Django request object. + + Returns: + dict[str, Any]: A dictionary containing the context with the key "BLOCK" + associated with the sorted list of blocks. + """ + block = Block.objects.filter(sites=get_current_site(request), visible=True).order_by("order") + return { + "BLOCK": block, + } diff --git a/pod/main/fixtures/first_block.json b/pod/main/fixtures/first_block.json new file mode 100644 index 0000000000..d87526bb76 --- /dev/null +++ b/pod/main/fixtures/first_block.json @@ -0,0 +1,18 @@ +[ + { + "pk": 1, + "model": "main.block", + "fields": { + "title": "Dernières vidéos", + "display_title_en": "Last videos", + "display_title_fr": "Dernières vidéos", + "type": "card_list", + "data_type": "last_videos", + "page": 1, + "sites": [ + 1 + ] + } + } + ] + \ No newline at end of file diff --git a/pod/main/models.py b/pod/main/models.py index f1501772c6..2e257640b2 100644 --- a/pod/main/models.py +++ b/pod/main/models.py @@ -10,6 +10,8 @@ from django.db import connection import os import mimetypes +from ckeditor.fields import RichTextField + FILES_DIR = getattr(settings, "FILES_DIR", "files") @@ -193,3 +195,178 @@ def __str__(self): class Meta: verbose_name = _("Additional channels Tab") verbose_name_plural = _("Additional channel Tabs") + + +class Block(models.Model): + """Class describing Block objects.""" + + CAROUSEL = "carousel" + MULTI_CAROUSEL = "multi_carousel" + CARD_LIST = "card_list" + HTML = "html" + TYPE = ( + (CAROUSEL, _("Carousel")), + (MULTI_CAROUSEL, _("Multiple carousel")), + (CARD_LIST, _("Card list")), + (HTML, _("HTML")), + ) + + CHANNEL = "channel" + THEME = "theme" + PLAYLIST = "playlist" + LAST_VIDEOS = "last_videos" + MOST_VIEWS = "most_views" + EVENT_NEXT = "event_next" + DATA_TYPE = ( + (CHANNEL, _('Channel')), + (THEME, _('Theme')), + (PLAYLIST, _('Playlist')), + (LAST_VIDEOS, _('Last videos')), + (MOST_VIEWS, _('Most views')), + (EVENT_NEXT, _('Next events')), + ) + + title = models.CharField(verbose_name=_("Title"), max_length=250, blank=True, null=True) + + order = models.PositiveSmallIntegerField( + verbose_name=_("Order"), default=1, blank=True, null=True + ) + + visible = models.BooleanField( + verbose_name=_("Visible"), + default=True, + help_text=_("Check this box if block is visible in page."), + ) + + page = models.ForeignKey( + FlatPage, + blank=True, + null=True, + on_delete=models.CASCADE, + help_text=_("Select the page of Pod you want to link with."), + ) + + sites = models.ManyToManyField(Site) + + type = models.CharField( + verbose_name=_("Type"), + max_length=200, + choices=TYPE, + default=CAROUSEL, + blank=True, + null=True, + ) + + data_type = models.CharField( + verbose_name=_("Data type"), + max_length=200, + choices=DATA_TYPE, + default=None, + blank=True, + null=True, + ) + + Channel = models.ForeignKey( + "video.Channel", + blank=True, + null=True, + on_delete=models.CASCADE, + help_text=_("Select the channel you want to link with."), + ) + + Theme = models.ForeignKey( + "video.Theme", + blank=True, + null=True, + on_delete=models.CASCADE, + help_text=_("Select the theme you want to link with."), + ) + + Playlist = models.ForeignKey( + "playlist.Playlist", + blank=True, + null=True, + on_delete=models.CASCADE, + help_text=_("Select the playlist you want to link with."), + ) + + html = RichTextField( + config_name="complete", + verbose_name=_("HTML"), + null=True, + blank=True, + help_text=_("Write in html inside this field."), + ) + + display_title = models.CharField( + verbose_name=_("Display title"), max_length=250, blank=True, null=True + ) + + no_cache = models.BooleanField( + default=True, + verbose_name=_("No cache"), + help_text=_("Check this box if you don't want to keep the cache."), + ) + + debug = models.BooleanField( + verbose_name=_("Debug"), + default=False, + help_text=_("Check this box if you want to activate debug mode."), + ) + + show_restricted = models.BooleanField( + verbose_name=_("Show restricted content"), + default=False, + help_text=_("Check this box if you want to show restricted content."), + ) + + must_be_auth = models.BooleanField( + verbose_name=_("Must be authenticated"), + default=False, + help_text=_("Check this box if users must be authenticated to view content."), + ) + + auto_slide = models.BooleanField( + verbose_name=_("Auto slide"), + default=False, + help_text=_("Check this box if you want auto slide."), + ) + + nb_element = models.PositiveIntegerField( + verbose_name=_("Maximum number of element"), default=5, blank=True, null=True + ) + + multi_carousel_nb = models.PositiveIntegerField( + verbose_name=_("Number of element per page (multi carousel)"), + default=5, + blank=True, + null=True, + ) + + view_videos_from_non_visible_channels = models.BooleanField( + verbose_name=_("View videos from non visible channel"), + default=False, + help_text=_( + "Check this box if you want view videos from non visible channel." + ), + ) + + shows_passworded = models.BooleanField( + verbose_name=_("View videos with password"), + default=False, + help_text=_("Check this box if you want view videos with password."), + ) + + def __str__(self): + return "%s" % (self.title) + + class Meta: + verbose_name = _("Block") + verbose_name_plural = _("Blocks") + + +@receiver(post_save, sender=Block) +def default_site_block(sender, instance, created, **kwargs): + """Sets a default site for the instance if it has no associated sites upon creation.""" + if len(instance.sites.all()) == 0: + instance.sites.add(Site.objects.get_current()) diff --git a/pod/main/settings.py b/pod/main/settings.py index 34878bd744..e85699ec49 100644 --- a/pod/main/settings.py +++ b/pod/main/settings.py @@ -281,7 +281,6 @@ # https://sorl-thumbnail.readthedocs.io/en/latest/reference/settings.html THUMBNAIL_PRESERVE_FORMAT = True -SHOW_EVENTS_ON_HOMEPAGE = False DEFAULT_EVENT_PATH = "" DEFAULT_EVENT_THUMBNAIL = "/img/default-event.svg" DEFAULT_EVENT_TYPE_ID = 1 diff --git a/pod/main/static/css/block.css b/pod/main/static/css/block.css new file mode 100644 index 0000000000..dc8af4dbbf --- /dev/null +++ b/pod/main/static/css/block.css @@ -0,0 +1,155 @@ +/***************************** + Editorial +******************************/ + +.video_time, +.time { + display: flex !important; + align-items: center; +} +.video_time:before, +.time:before { + content: "\f293"; + font-family: bootstrap-icons; + margin-right: 10px; +} + +/* Carousel*/ + +.edito-carousel .carousel-caption.d-md-block { + text-align: left; + display: flex !important; + flex-direction: column; + justify-content: center; + height: inherit; + width: 100%; + bottom: 0; + left: 0; + right: 0; + background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.7) 40%); + padding: 5em 3em 3em 3em; +} + +.edito-carousel .video_title { + font-size: 36px; + font-family: var(--bs-body-font-family); +} +.edito-carousel .video_title:after { + content: ""; + display: block; + width: 90px; + height: 3px; + background-color: var(--pod-primary); + margin-top: 15px; +} +.edito-carousel .video_time { + font-size: 18px; + font-weight: 700; + color: #fff !important; +} +.edito-carousel .video_time:before { + color: var(--pod-primary); +} + +.edito-carousel .carousel-indicators [data-bs-target] { + border: 3px solid #fff; + height: 15px; + width: 15px; + background: none; + border-radius: 100%; +} + +.edito-carousel .carousel-indicators button:hover, +.edito-carousel .carousel-indicators button:focus { + border-color: var(--pod-primary); + opacity: 1; +} + +/* Multiple carousel */ + +.edito-multi-carousel .carousel-control-next, +.edito-multi-carousel .carousel-control-prev { + width: 2%; + height: 185px; +} +.edito-multi-carousel .carousel-control-prev { + background-image: linear-gradient( + to left, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 0.3) 40% + ); +} +.edito-multi-carousel .carousel-control-next { + background-image: linear-gradient( + to right, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 0.3) 40% + ); +} + +.edito-multi-carousel .carousel-inner .carousel-item.active, +.edito-multi-carousel .carousel-inner .carousel-item-next, +.edito-multi-carousel .carousel-inner .carousel-item-prev { + display: flex; +} + +.edito-multi-carousel .carousel-item .card { + margin-right: 20px; +} + +.edito-multi-carousel .carousel-item .col-md-4 { + background-color: #fafafa; +} + +.edito-multi-carousel .carousel-item .col-md-3:last-child .card, +.edito-multi-carousel .carousel-item .col-md-4:last-child .card { + margin: 0; +} + +.edito-multi-carousel img { + height: 185px; + object-fit: cover; + width: 100%; +} +.edito-multi-carousel .carousel-caption { + position: initial; + color: #000; + padding: 1rem 0; + background-color: #fafafa; +} +.edito-multi-carousel a { + text-decoration: none; +} +.edito-multi-carousel .video_desc { + display: none; +} +.edito-multi-carousel .video_time { + justify-content: center; +} + +/* Multiple carousel */ +@media (min-width: 768px) { + .edito-multi-carousel .carousel-inner .carousel-item-end.active, + .edito-multi-carousel .carousel-inner .carousel-item-next { + transform: translateX(25%); + } + + .edito-multi-carousel .carousel-inner .carousel-item-start.active, + .edito-multi-carousel .carousel-inner .carousel-item-prev { + transform: translateX(-25%); + } + + .edito-multi-carousel .carousel-inner .carousel-item-end, + .edito-multi-carousel .carousel-inner .carousel-item-start { + transform: translateX(0); + } +} + +@media (max-width: 767px) { + .edito-multi-carousel .carousel-inner .carousel-item > div { + display: none; + } + .edito-multi-carousel .carousel-inner .carousel-item > div:first-child { + display: block; + } +} diff --git a/pod/main/static/js/admin.js b/pod/main/static/js/admin.js new file mode 100644 index 0000000000..7464bdd86b --- /dev/null +++ b/pod/main/static/js/admin.js @@ -0,0 +1,127 @@ +/** + * @file Esup-Pod script for admin panel. + * @since 3.6.0 + */ +const selectedType = document.getElementById("id_type"); +const selectedDataType = document.getElementById("id_data_type"); +fieldDataType = document.getElementsByClassName("field-data_type")[0]; +fieldChannel = document.getElementsByClassName("field-Channel")[0]; +fieldTheme = document.getElementsByClassName("field-Theme")[0]; +fieldPlaylist = document.getElementsByClassName("field-Playlist")[0]; +fieldHtml = document.getElementsByClassName("field-html")[0]; + +/** + * Function for show field. + * + * @param {HTMLElement} field The field to show. + */ +function showField(field) { + field.classList.remove("d-none"); + field.classList.add("d-block"); +} + +/** + * Function for hide field. + * + * @param {HTMLElement} field The field to hide. + */ +function hideField(field) { + field.classList.remove("d-block"); + field.classList.add("d-none"); +} + +/** + * Function init. + */ +function initializeFieldDisplay() { + if (selectedType && selectedDataType) { + if (selectedType.value === "html") { + showField(fieldHtml); + hideField(fieldDataType); + } else { + hideField(fieldHtml); + showField(fieldDataType); + } + + switch (selectedDataType.value) { + case "channel": + showField(fieldChannel); + break; + case "theme": + showField(fieldTheme); + break; + case "playlist": + showField(fieldPlaylist); + break; + default: + hideField(fieldChannel); + hideField(fieldTheme); + hideField(fieldPlaylist); + break; + } + } +} + +/** + * Listen selectedType and selectedDataType if change. + */ +if (selectedType) { + selectedType.addEventListener("change", function () { + handleTypeChange(); + handleDataTypeChange(); + }); +} + +if (selectedDataType) { + selectedDataType.addEventListener("change", handleDataTypeChange); +} + +/** + * Function change Type. + */ +function handleTypeChange() { + if (selectedType && selectedDataType) { + if (selectedType.value === "html") { + showField(fieldHtml); + hideField(fieldDataType); + } else { + hideField(fieldHtml); + showField(fieldDataType); + } + } +} + +/** + * Function change Data Type. + */ +function handleDataTypeChange() { + if (selectedType && selectedDataType) { + switch (selectedDataType.value) { + case "channel": + showField(fieldChannel); + hideField(fieldTheme); + hideField(fieldPlaylist); + break; + case "theme": + showField(fieldTheme); + hideField(fieldChannel); + hideField(fieldPlaylist); + break; + case "playlist": + showField(fieldPlaylist); + hideField(fieldChannel); + hideField(fieldTheme); + break; + default: + hideField(fieldChannel); + hideField(fieldTheme); + hideField(fieldPlaylist); + break; + } + } +} + +/** + * Call init function. + */ +initializeFieldDisplay(); diff --git a/pod/main/templates/admin/base_site.html b/pod/main/templates/admin/base_site.html index be21e108bd..7284ea14da 100644 --- a/pod/main/templates/admin/base_site.html +++ b/pod/main/templates/admin/base_site.html @@ -41,3 +41,8 @@

{{ site_header|default:_('D {% endblock %} {% block nav-global %}{% endblock %} + +{% block footer %} +{{ block.super }} + +{% endblock %} diff --git a/pod/main/templates/base.html b/pod/main/templates/base.html index 07fc9580e0..6403535632 100644 --- a/pod/main/templates/base.html +++ b/pod/main/templates/base.html @@ -27,6 +27,8 @@ + + {% if DARKMODE_ENABLED == True %} {% endif %} @@ -36,7 +38,7 @@ {% if CSS_OVERRIDE %} {% endif %} - {% if SHOW_EVENTS_ON_HOMEPAGE and "live" in THIRD_PARTY_APPS %} + {% if "live" in THIRD_PARTY_APPS %} {% endif %} @@ -128,12 +130,6 @@

{{page_title|capfirst}}

{% endblock main_page_title %} {% block page_content %}{% endblock page_content %} - {% if request.path == "/" %} - {% if SHOW_EVENTS_ON_HOMEPAGE and "live" in THIRD_PARTY_APPS %} - {% include "live/events_next.html" %} - {% endif %} - {% include "videos/last_videos.html" %} - {% endif %} diff --git a/pod/main/templates/block/card_list.html b/pod/main/templates/block/card_list.html new file mode 100644 index 0000000000..1ddfa82932 --- /dev/null +++ b/pod/main/templates/block/card_list.html @@ -0,0 +1,24 @@ +{% load i18n %} {% load video_filters static %} {% if elements.count >= 1 %} + +
+ {% endif %} diff --git a/pod/main/templates/block/carousel.html b/pod/main/templates/block/carousel.html new file mode 100644 index 0000000000..d7a5c80e55 --- /dev/null +++ b/pod/main/templates/block/carousel.html @@ -0,0 +1,99 @@ +{% load i18n %} {% load video_filters static %} {% if elements.count >= 1 %} + diff --git a/pod/main/templates/block/html.html b/pod/main/templates/block/html.html new file mode 100644 index 0000000000..726ba75837 --- /dev/null +++ b/pod/main/templates/block/html.html @@ -0,0 +1,3 @@ +{% load i18n %} {% load video_filters static %} + +
{{ body|safe }}
diff --git a/pod/main/templates/block/multi_carousel.html b/pod/main/templates/block/multi_carousel.html new file mode 100644 index 0000000000..9541bfe5c0 --- /dev/null +++ b/pod/main/templates/block/multi_carousel.html @@ -0,0 +1,109 @@ +{% load i18n %} {% load video_filters static %} {% if elements.count >= 1 %} + diff --git a/pod/main/templates/flatpages/default.html b/pod/main/templates/flatpages/default.html index 85834e5065..29c14c0484 100644 --- a/pod/main/templates/flatpages/default.html +++ b/pod/main/templates/flatpages/default.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load flat_page_edito_filter %} {% block opengraph %}{% load video_filters static %} @@ -24,4 +25,13 @@ {% block page_content %} {{ flatpage.content }} + + {% if BLOCK %} + {% for link in BLOCK %} + {% if flatpage.url == link.page.url %} + {{link|edito:request|safe}} + {% endif %} + {% endfor %} + {% endif %} + {% endblock page_content %} diff --git a/pod/main/templatetags/flat_page_edito_filter.py b/pod/main/templatetags/flat_page_edito_filter.py new file mode 100644 index 0000000000..bcb394b232 --- /dev/null +++ b/pod/main/templatetags/flat_page_edito_filter.py @@ -0,0 +1,392 @@ +"""Esup-Pod video custom filters.""" + +import hashlib +import html +import random +import string +from datetime import date, datetime +from html.parser import HTMLParser + +from django import template +from django.conf import settings +from django.contrib.sites.shortcuts import get_current_site +from django.core.cache import cache +from django.db.models import Q, Sum +from django.template import loader +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from pod.live.models import Event +from pod.video.models import Video +from pod.playlist.models import PlaylistContent + +register = template.Library() +parser = HTMLParser() +html_parser = html + +__DEFAULT_NB_ELEMENT__ = 5 +__DEFAULT_NB_CARD__ = 5 +__DEFAULT_TITLE__ = "" + +VIDEO_RECENT_VIEWCOUNT = getattr(settings, "VIDEO_RECENT_VIEWCOUNT", 180) +EDITO_CACHE_TIMEOUT = getattr(settings, "EDITO_CACHE_TIMEOUT", 300) +EDITO_CACHE_PREFIX = getattr(settings, "EDITO_CACHE_PREFIX", "edito_cache_") + + +@register.filter(name="edito") +def edito(content, request): + """Return block content.""" + block = display_content_by_block(content, request) + return block + + +def display_content_by_block(content, request): # noqa: C901 + """Display content by block.""" + debug_elts = [] + + current_site = get_current_site(request) + + md5_part = hashlib.md5(str(content).encode("utf-8")).hexdigest() + + params = dict() + + params["show-restricted"] = content.show_restricted + params["view-videos-from-non-visible-channels"] = content.view_videos_from_non_visible_channels + params["show-passworded"] = content.shows_passworded + params["mustbe-auth"] = content.must_be_auth + params["auto-slide"] = content.auto_slide + params["debug"] = content.debug + + if "debug" in request.GET: + if request.GET["debug"] is True: + params["debug"] = True + + if content.no_cache is True: + params["cache"] = False + debug_elts.append("Cache is disable for this part") + else: + params["cache"] = True + debug_elts.append("Cache is enable for this part") + + if content.nb_element is not None or content.nb_element != "": + params["nb-element"] = int(content.nb_element) + else: + params["nb-element"] = __DEFAULT_NB_ELEMENT__ + + if content.multi_carousel_nb is not None or content.multi_carousel_nb != "": + params["multi-carousel-nb-card"] = int(content.multi_carousel_nb) + else: + params["multi-carousel-nb-card"] = __DEFAULT_NB_CARD__ + + if content.display_title is not None: + params["title"] = content.display_title + else: + params["title"] = __DEFAULT_TITLE__ + + if content.data_type == "channel": + params["fct"] = "render_base_videos" + params["container"] = "channel" + params["data"] = content.Channel + + if content.data_type == "theme": + params["fct"] = "render_base_videos" + params["container"] = "theme" + params["data"] = content.Theme + + if content.data_type == "playlist": + params["fct"] = "render_base_videos" + params["container"] = "playlist" + params["data"] = content.Playlist + + if content.data_type == "event_next": + params["fct"] = "render_next_events" + + if content.data_type == "most_views": + params["fct"] = "render_most_view" + + if content.data_type == "last_videos": + params["fct"] = "render_last_view" + + if (content.type == 'html'): + params['template'] = 'block/html.html' + params['fct'] = 'render_html' + params['data'] = content.html + + if content.type == "carousel": + params["template"] = "block/carousel.html" + + if content.type == "multi_carousel": + params["template"] = "block/multi_carousel.html" + + if content.type == "card_list": + params["template"] = "block/card_list.html" + + cached_content_part = None + + if params["mustbe-auth"] and not request.user.is_authenticated: + debug_elts.append("User is not authenticated and mustbe-auth=true hide result") + content_part = "" + else: + if params["cache"]: + cached_content_part = cache.get(EDITO_CACHE_PREFIX + md5_part) + + if cached_content_part: + debug_elts.append("Found in cache") + content_part = cached_content_part + else: + debug_elts.append("Not found in cache") + uniq_id = "".join(random.choice(string.ascii_letters) for i in range(20)) + content_part = globals()["%s" % params["fct"]]( + uniq_id, params, current_site, debug_elts + ) + cache.set( + EDITO_CACHE_PREFIX + md5_part, content_part, timeout=EDITO_CACHE_TIMEOUT + ) + + if params["debug"]: + content_part = "
%s
%s" % ("\n".join(debug_elts), content_part) + + content = content_part + + return content + + +def render_base_videos(uniq_id, params, current_site, debug_elts): + """Render block with videos in channel or theme or playlist.""" + debug_elts.append("Call function render_base_videos") + + container = params["data"] + + filters = {} + + if params["container"] == "playlist": + container_playlist = PlaylistContent.objects.filter(playlist=params["data"]) + container_playlistContent = [list.video.id for list in container_playlist] + filters["id__in"] = container_playlistContent + else: + if params["container"] == "theme": + container_childrens = container.get_all_children_flat() + filters["theme__in"] = container_childrens + if params["debug"] and len(container_childrens) > 1: + debug_elts.append("Theme has children(s)") + debug_elts.extend( + [ + f" - children found [ID:{children.id}] [SLUG:{children.slug}] [TITLE:{children.title}]" + for children in container_childrens + ] + ) + else: + filters[params["container"]] = container + + filters.update( + { + "encoding_in_progress": False, + "is_draft": False, + "sites": current_site, + } + ) + + filter_q = Q(**filters) + query = Video.objects.filter(filter_q) + query = add_filter(params, debug_elts, query) + + videos = ( + query.all() + .defer("video", "slug", "description") + .distinct()[: params["nb-element"]] + ) + + if params["debug"]: + debug_elts.append(f"Database query '{str(videos.query)}'") + debug_elts.append("Found videos in container :") + debug_elts.extend( + [ + f" - Video informations : [ID:{video.id}] [SLUG:{video.slug}] [TITLE:{video.title}]" + for video in videos + ] + ) + + title = container.name if params["container"] == "playlist" else container.title + title = title if params["title"] == "" else params["title"] + + params["multi-carousel-nb-card"] = min( + params["multi-carousel-nb-card"], videos.count() + ) + + part_content = loader.get_template(params["template"]).render( + { + "uniq_id": uniq_id, + "container": container, + "title": title, + "type": "video", + "elements": videos, + "nb_element": params["nb-element"], + "auto_slide": params["auto-slide"], + "multi_carousel_nb_card": params["multi-carousel-nb-card"], + } + ) + + return part_content + + +def render_most_view(uniq_id, params, current_site, debug_elts): + """Render block with most view videos.""" + debug_elts.append("Call function render_most_view") + + d = date.today() - timezone.timedelta(days=VIDEO_RECENT_VIEWCOUNT) + query = Video.objects.filter( + Q(encoding_in_progress=False) + & Q(is_draft=False) + & Q(viewcount__date__gte=d) + & Q(sites=current_site) + ).annotate(nombre=Sum("viewcount")) + + query = add_filter(params, debug_elts, query) + + most_viewed_videos = query.all().order_by("-nombre")[: int(params["nb-element"])] + + debug_elts.append(f"Database query '{str(most_viewed_videos.query)}'") + + debug_elts.append("Found videos in container :") + + for video in most_viewed_videos: + debug_elts.append( + f" - Video informations : " + f"[ID:{video.id}] [SLUG:{video.slug}] [RECENT_VIW_COUNT:{video.recentViewcount}]" + ) + + if most_viewed_videos.count() < params["multi-carousel-nb-card"]: + params["multi-carousel-nb-card"] = most_viewed_videos.count() + + if params["title"] == "": + title = _("Most views") + else: + title = params["title"] + + part_content = loader.get_template(params["template"]).render( + { + "uniq_id": uniq_id, + "title": title, + "type": "video", + "elements": most_viewed_videos, + "nb_element": params["nb-element"], + "auto_slide": params["auto-slide"], + "multi_carousel_nb_card": params["multi-carousel-nb-card"], + } + ) + return "%s" % (part_content) + + +def render_next_events(uniq_id, params, current_site, debug_elts): + """Render block with next events.""" + debug_elts.append("Call function render_next_events") + + query = ( + Event.objects.filter(is_draft=False) + .exclude(end_date__lt=datetime.now()) + .filter(broadcaster__building__sites__exact=current_site) + ) + + if not params["show-restricted"]: + query = query.filter(is_restricted=False) + + event_list = query.all().order_by("start_date")[: params["nb-element"]] + + if params["debug"]: + debug_elts.append(f"Database query '{event_list.query}'") + debug_elts.append("Found events in container :") + for event in event_list: + debug_elts.append( + f" - Video informations is [ID:{event.id}] [SLUG:{event.slug}] [TITLE:{event.title}]" + ) + + if event_list.count() < params["multi-carousel-nb-card"]: + params["multi-carousel-nb-card"] = event_list.count() + + if params["title"] == "": + title = _("Next events") + else: + title = params["title"] + + part_content = loader.get_template(params["template"]).render( + { + "uniq_id": uniq_id, + "title": title, + "type": "event", + "elements": event_list, + "auto_slide": params["auto-slide"], + "multi_carousel_nb_card": params["multi-carousel-nb-card"], + } + ) + return part_content + + +def render_html(uniq_id, params, current_site, debug_elts): + """Render block with html content.""" + debug_elts.append("Call function render_html") + + part_content = loader.get_template(params["template"]).render( + {"body": params["data"]} + ) + return "%s" % (part_content) + + +def render_last_view(uniq_id, params, current_site, debug_elts): + """Render block with last view videos.""" + debug_elts.append("Call function render_last_view") + + query = Video.objects.filter( + Q(encoding_in_progress=False) & Q(is_draft=False) & Q(sites=current_site) + ) + + query = add_filter(params, debug_elts, query) + + last_viewed_videos = query.all()[: int(params["nb-element"])] + + debug_elts.append(f"Database query '{str(last_viewed_videos.query)}'") + + debug_elts.append("Found videos in container :") + + for video in last_viewed_videos: + debug_elts.append( + f" - Video informations : " + f"[ID:{video.id}] [SLUG:{video.slug}] [RECENT_VIW_COUNT:{video.recentViewcount}]" + ) + + if last_viewed_videos.count() < params["multi-carousel-nb-card"]: + params["multi-carousel-nb-card"] = last_viewed_videos.count() + + if params["title"] == "": + title = _("Last videos") + else: + title = params["title"] + + part_content = loader.get_template(params["template"]).render( + { + "uniq_id": uniq_id, + "title": title, + "type": "last", + "elements": last_viewed_videos, + "nb_element": params["nb-element"], + "auto_slide": params["auto-slide"], + "multi_carousel_nb_card": params["multi-carousel-nb-card"], + } + ) + return part_content + + +def add_filter(params, debug_elts, data): + """Create filter for videos list.""" + if not params["show-restricted"]: + data = data.filter(is_restricted=False) + debug_elts.append("apply filter not show restricted") + + if not params["view-videos-from-non-visible-channels"]: + data = data.exclude(channel__visible=0) + debug_elts.append("apply filter not show videos non visible channels") + + if not params["show-passworded"]: + data = data.filter(Q(password="") | Q(password__isnull=True)) + debug_elts.append("apply filter not show videos password") + + return data diff --git a/pod/main/test_settings.py b/pod/main/test_settings.py index d54bffa06c..330cedcfc7 100644 --- a/pod/main/test_settings.py +++ b/pod/main/test_settings.py @@ -57,7 +57,6 @@ USE_STATS_VIEW = True ACCOMMODATION_YEARS = {"faculty": 1} USE_OBSOLESCENCE = True -SHOW_EVENTS_ON_HOMEPAGE = True ACTIVE_VIDEO_COMMENT = True USER_VIDEO_CATEGORY = True POD_ARCHIVE_AFFILIATION = ["faculty"] diff --git a/pod/main/tests/test_models.py b/pod/main/tests/test_models.py index 8e214957ce..f8029215e7 100644 --- a/pod/main/tests/test_models.py +++ b/pod/main/tests/test_models.py @@ -4,7 +4,7 @@ from django.contrib.flatpages.models import FlatPage from django.conf import settings from django.contrib.sites.models import Site -from pod.main.models import Configuration, AdditionalChannelTab +from pod.main.models import Configuration, AdditionalChannelTab, Block SITE_ID = getattr(settings, "SITE_ID", 1) @@ -143,3 +143,21 @@ def test_delete_object(self): self.assertEquals(AdditionalChannelTab.objects.filter(name="Tab0").count(), 0) print("---> test_delete_object of AdditionalChannelTabTestCase: OK!") + + +class BlockTestCase(TestCase): + def setUp(self): + print(" ---> SetUp of BlockTestCase: OK!") + + def test_default_site_assigned_on_creation(self): + + """ + Test if add block assign default site. + """ + + block = Block.objects.create(title="Test Block") + default_site = Site.objects.get(id=SITE_ID) + self.assertEqual(block.sites.count(), 1) + self.assertEqual(block.sites.first(), default_site) + + print(" ---> test add block with default site assign: OK!") diff --git a/pod/main/tests/test_views.py b/pod/main/tests/test_views.py index 85243b3593..92beb18604 100644 --- a/pod/main/tests/test_views.py +++ b/pod/main/tests/test_views.py @@ -8,15 +8,18 @@ from django.test import Client from django.urls import reverse from django.utils.translation import ugettext_lazy as _ +from django.contrib.flatpages.models import FlatPage from django.contrib.auth.models import User from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.conf import settings from captcha.models import CaptchaStore from http import HTTPStatus +from datetime import datetime, timedelta from pod.main import context_processors -from pod.main.models import Configuration +from pod.main.models import Configuration, Block from pod.playlist.models import Playlist -from pod.video.models import Type, Video +from pod.video.models import Type, Video, Channel, ViewCount +from pod.live.models import Building, Broadcaster, Event import os import importlib @@ -519,3 +522,229 @@ def test_statistics_playlists(self): ) print(" ---> test_statistics_playlists ok") + + +class TestBlock(TestCase): + """Block tests case.""" + + fixtures = ["initial_data.json"] + + def setUp(self): + print(" ---> init blocktest ok") + + def test_html_block_content(self): + """ + Test html block. + """ + bl2 = Block.objects.create( + title="block html", + type="html", + page=FlatPage.objects.get(id=1), + html="

MaChaineDeTest

", + no_cache=True, + visible=True + ) + self.client = Client() + response = self.client.get("/") + self.assertTrue( + '

MaChaineDeTest

' in response.content.decode(), + "test if block html is present.", + ) + bl2.visible = False + bl2.save() + response = self.client.get("/") + self.assertFalse( + '

MaChaineDeTest

' in response.content.decode(), + "test if block html is not present.", + ) + bl2.visible = True + bl2.must_be_auth = True + bl2.save() + response = self.client.get("/") + self.assertFalse( + '

MaChaineDeTest

' in response.content.decode(), + "test if block html is not present.", + ) + + User.objects.create(username="test", password="azerty") + self.user = User.objects.get(username="test") + self.client.force_login(self.user) + response = self.client.get("/") + self.assertTrue( + '

MaChaineDeTest

' in response.content.decode(), + "test if block html is present.", + ) + + print(" ---> test_Block_Html ok") + + def test_default_block(self): + """ + Test when add video if present in default block. + """ + user = User.objects.create(username="pod", password="podv3") + Video.objects.create( + title="VideoOnHold", + owner=user, + video="test.mp4", + type=Type.objects.get(id=1), + is_draft=False, + slug="video-on-hold", + duration=20, + date_added=datetime.today(), + encoding_in_progress=False, + date_evt=datetime.today(), + ) + self.client = Client() + response = self.client.get("/") + self.assertTrue( + 'VideoOnHold' in response.content.decode(), + "test if video VideoOnHold is present.", + ) + print(" ---> test_Video_in_default_block ok") + + def test_channel_type_block(self): + """ + Test if create channel with video, this video is present in block type channel. + """ + bk1 = Block.objects.get(id=1) + bk1.visible = False + bk1.save() + channel = Channel.objects.create(title="monChannel") + user = User.objects.create(username="pod", password="podv3") + video = Video.objects.create( + title="VideoOnHold", + owner=user, + video="test.mp4", + type=Type.objects.get(id=1), + is_draft=False, + slug="video-on-hold", + duration=20, + date_added=datetime.today(), + encoding_in_progress=False, + date_evt=datetime.today(), + ) + video.channel.add(channel) + bl2 = Block.objects.create( + title="block channel", + type="carousel", + page=FlatPage.objects.get(id=1), + data_type="channel", + Channel=Channel.objects.get(id=1), + no_cache=True, + visible=True, + view_videos_from_non_visible_channels=False, + ) + + self.client = Client() + response = self.client.get("/") + + self.assertFalse( + 'VideoOnHold' in response.content.decode(), + "test if video VideoOnHold is not present.", + ) + + bl2.view_videos_from_non_visible_channels = True + bl2.save() + response = self.client.get("/") + + self.assertTrue( + 'VideoOnHold' in response.content.decode(), + "test if video VideoOnHold is present.", + ) + + bl2.view_videos_from_non_visible_channels = False + bl2.save() + channel.visible = True + channel.save() + response = self.client.get("/") + + self.assertTrue( + 'VideoOnHold' in response.content.decode(), + "test if video VideoOnHold is present.", + ) + self.assertTrue( + ' +{% endif %} + {% endblock page_content %} {% block page_aside %} diff --git a/pod/cut/tests/test_views.py b/pod/cut/tests/test_views.py index 544c88ae1a..cec982dc02 100644 --- a/pod/cut/tests/test_views.py +++ b/pod/cut/tests/test_views.py @@ -9,6 +9,8 @@ from datetime import time from django.contrib.messages import get_messages from django.utils.translation import ugettext_lazy as _ +from .. import views +from importlib import reload class CutVideoViewsTestCase(TestCase): @@ -58,13 +60,13 @@ def test_get_full_duration(self): self.assertEqual(duration, 10) print(" ---> test_get_full_duration ok") - @override_settings(RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY=True) + @override_settings(RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY=True, USE_CUT=True) def test_restrict_edit_video_access_staff_only(self): """Test test_restrict_edit_video_access_staff_only.""" + reload(views) self.client.force_login(self.user2) url = reverse("cut:video_cut", kwargs={"slug": self.video.slug}) response = self.client.get(url) - self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "video_cut.html") self.assertTrue(response.context["access_not_allowed"]) diff --git a/pod/cut/views.py b/pod/cut/views.py index aaedf1f0d4..7d38e2a0a6 100644 --- a/pod/cut/views.py +++ b/pod/cut/views.py @@ -20,6 +20,10 @@ from django.conf import settings +RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY = getattr( + settings, "RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY", False +) + @csrf_protect @login_required(redirect_field_name="referrer") @@ -37,7 +41,7 @@ def cut_video(request, slug): # noqa: C901 duration = cutting.duration if ( - settings.RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY + RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False ): return render( diff --git a/pod/locale/fr/LC_MESSAGES/django.mo b/pod/locale/fr/LC_MESSAGES/django.mo index 083cdba51080c0315202708bf405f61d9ae6b3f8..4635b01fb065d43474dc447715b45310daf86175 100644 GIT binary patch delta 42077 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 diff --git a/pod/locale/fr/LC_MESSAGES/django.po b/pod/locale/fr/LC_MESSAGES/django.po index 30896fff58..98de6ba299 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-10 14:22+0000\n" +"POT-Creation-Date: 2024-04-11 20:03+0000\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -1896,6 +1896,21 @@ msgstr "Découper la vidéo" msgid "The video is currently being encoded." msgstr "La vidéo est en cours d’encodage." +#: pod/cut/templates/video_cut.html +msgid "" +"Access to cutting video has been restricted. If you want to cut videos on " +"the platform, please" +msgstr "" +"L’accès au découpage de vidéo est restreint, si vous voulez couper des " +"vidéos, veuillez" + +#: pod/cut/templates/video_cut.html pod/live/templates/live/event_edit.html +#: pod/live/templates/live/event_immediate_edit.html +#: pod/video/templates/videos/add_video.html +#: pod/video/templates/videos/video_edit.html +msgid "contact us" +msgstr "nous contacter" + #: pod/cut/templates/video_cut.html msgid "Get the start time from the video player" msgstr "Définir le temps de début à partir du lecteur vidéo" @@ -3885,13 +3900,6 @@ msgstr "" "L’accès à l’ajout d’évènement est restreint. Si vous voulez ajouter des " "évènements, veuillez" -#: pod/live/templates/live/event_edit.html -#: pod/live/templates/live/event_immediate_edit.html -#: pod/video/templates/videos/add_video.html -#: pod/video/templates/videos/video_edit.html -msgid "contact us" -msgstr "nous contacter" - #: pod/live/templates/live/event_edit.html msgid "The event is currently in progress. Editing options are limited." msgstr "" diff --git a/pod/locale/fr/LC_MESSAGES/djangojs.po b/pod/locale/fr/LC_MESSAGES/djangojs.po index ba23d97569..cfeaf8793e 100644 --- a/pod/locale/fr/LC_MESSAGES/djangojs.po +++ b/pod/locale/fr/LC_MESSAGES/djangojs.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Esup-Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-10 14:22+0000\n" +"POT-Creation-Date: 2024-04-11 20:03+0000\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: \n" diff --git a/pod/locale/nl/LC_MESSAGES/django.po b/pod/locale/nl/LC_MESSAGES/django.po index d8b390666b..00fee24050 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-10 14:22+0000\n" +"POT-Creation-Date: 2024-04-11 20:03+0000\n" "PO-Revision-Date: 2023-06-08 14:37+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -1806,6 +1806,19 @@ msgstr "" msgid "The video is currently being encoded." msgstr "" +#: pod/cut/templates/video_cut.html +msgid "" +"Access to cutting video has been restricted. If you want to cut videos on " +"the platform, please" +msgstr "" + +#: pod/cut/templates/video_cut.html pod/live/templates/live/event_edit.html +#: pod/live/templates/live/event_immediate_edit.html +#: pod/video/templates/videos/add_video.html +#: pod/video/templates/videos/video_edit.html +msgid "contact us" +msgstr "" + #: pod/cut/templates/video_cut.html msgid "Get the start time from the video player" msgstr "" @@ -3641,13 +3654,6 @@ msgid "" "platform, please" msgstr "" -#: pod/live/templates/live/event_edit.html -#: pod/live/templates/live/event_immediate_edit.html -#: pod/video/templates/videos/add_video.html -#: pod/video/templates/videos/video_edit.html -msgid "contact us" -msgstr "" - #: pod/live/templates/live/event_edit.html msgid "The event is currently in progress. Editing options are limited." msgstr "" diff --git a/pod/locale/nl/LC_MESSAGES/djangojs.po b/pod/locale/nl/LC_MESSAGES/djangojs.po index 5523a0a011..5cf86873e7 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-10 14:22+0000\n" +"POT-Creation-Date: 2024-04-11 20:03+0000\n" "PO-Revision-Date: 2023-02-08 15:22+0100\n" "Last-Translator: obado \n" "Language-Team: \n" diff --git a/pod/video/templates/videos/link_video.html b/pod/video/templates/videos/link_video.html index c7bde812c7..6067ea1346 100644 --- a/pod/video/templates/videos/link_video.html +++ b/pod/video/templates/videos/link_video.html @@ -51,7 +51,7 @@ {% endif %} {% if video.encoded and video.encoding_in_progress is False %} {% if USE_CUT %} - {% if video.owner == request.user or request.user.is_superuser or perms.cut.cut_video %} + {% if video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all or perms.cut.cut_video %} From a43930c45ac2fef5234939e83047a29cf10b31a3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 12 Apr 2024 08:32:28 +0000 Subject: [PATCH 15/37] Fixup. Format code with Black --- pod/cut/views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pod/cut/views.py b/pod/cut/views.py index 7d38e2a0a6..a5e7ddf684 100644 --- a/pod/cut/views.py +++ b/pod/cut/views.py @@ -40,10 +40,7 @@ def cut_video(request, slug): # noqa: C901 cutting = CutVideo.objects.get(video=video.id) duration = cutting.duration - if ( - RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY - and request.user.is_staff is False - ): + if RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False: return render( request, "video_cut.html", {"access_not_allowed": True, "video": video} ) From 5e4a16d9b6a3f621b126b1ac6677ea60deb0493d Mon Sep 17 00:00:00 2001 From: MattBild <34771705+mattbild@users.noreply.github.com> Date: Fri, 12 Apr 2024 10:42:54 +0200 Subject: [PATCH 16/37] fix clean opencast folder method (#1099) --- pod/recorder/plugins/type_studio.py | 4 ++-- pod/recorder/utils.py | 24 +++++++++++++----------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/pod/recorder/plugins/type_studio.py b/pod/recorder/plugins/type_studio.py index 1561e4e195..0402c82268 100644 --- a/pod/recorder/plugins/type_studio.py +++ b/pod/recorder/plugins/type_studio.py @@ -8,7 +8,7 @@ from django.conf import settings -from ..utils import add_comment, studio_clean_old_files +from ..utils import add_comment, studio_clean_old_entries from pod.video.models import Video, get_storage_path_video from pod.video_encode_transcript import encode from django.template.defaultfilters import slugify @@ -93,7 +93,7 @@ def save_basic_video(recording, video_src): # Rename the XML file # os.rename(recording.source_file, recording.source_file + "_treated") - studio_clean_old_files() + studio_clean_old_entries() return video diff --git a/pod/recorder/utils.py b/pod/recorder/utils.py index 95ecd9c1ce..b210fcf23b 100644 --- a/pod/recorder/utils.py +++ b/pod/recorder/utils.py @@ -1,5 +1,5 @@ """Esup-Pod recorder utilities.""" - +import shutil import time import os import uuid @@ -20,21 +20,23 @@ def add_comment(recording_id, comment): recording.save() -def studio_clean_old_files(): +def studio_clean_old_entries(): """ - Clean up old files in the "opencast-files" folder. + Clean up old entries in the opencast folder. - The function removes files that are older than 7 days - from the "opencast-files" folder in the media root. + The function removes entries that are older than 7 days + from the opencast folder in the media root. """ - folder_to_clean = os.path.join(settings.MEDIA_ROOT, "opencast-files") + folder_to_clean = os.path.join(settings.MEDIA_ROOT, OPENCAST_FILES_DIR) now = time.time() - for f in os.listdir(folder_to_clean): - f = os.path.join(folder_to_clean, f) - if os.stat(f).st_mtime < now - 7 * 86400: - if os.path.isfile(f): - os.remove(f) + for entry in os.listdir(folder_to_clean): + entry_path = os.path.join(folder_to_clean, entry) + if os.stat(entry_path).st_mtime < now - 7 * 86400: + if os.path.isdir(entry_path): + shutil.rmtree(entry_path) + else: + os.remove(entry_path) def handle_upload_file(request, element_name, mimetype, tag_name): From a49094e11b26f48ef9b9513f5de787c7bd0c5ac4 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 12 Apr 2024 08:43:27 +0000 Subject: [PATCH 17/37] Fixup. Format code with Black --- pod/recorder/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pod/recorder/utils.py b/pod/recorder/utils.py index b210fcf23b..8419b835a7 100644 --- a/pod/recorder/utils.py +++ b/pod/recorder/utils.py @@ -1,4 +1,5 @@ """Esup-Pod recorder utilities.""" + import shutil import time import os 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 18/37] [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 + ) From 005d4d9b41e967b9cc8daf2a0dc240eb4ecf2c0e Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 12 Apr 2024 11:33:07 +0000 Subject: [PATCH 19/37] Fixup. Format code with Prettier --- pod/meeting/static/css/meeting.css | 2 +- pod/meeting/static/js/my_meetings.js | 46 ++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/pod/meeting/static/css/meeting.css b/pod/meeting/static/css/meeting.css index 28a4ea8a17..30fa5de52a 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; } diff --git a/pod/meeting/static/js/my_meetings.js b/pod/meeting/static/js/my_meetings.js index be68d26e3a..dc8543d273 100644 --- a/pod/meeting/static/js/my_meetings.js +++ b/pod/meeting/static/js/my_meetings.js @@ -9,9 +9,11 @@ meetingModal.addEventListener("show.bs.modal", function (event) { const title = button.getAttribute("data-bs-meeting-title"); 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 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"); + const restartLiveUrl = button.getAttribute( + "data-bs-meeting-restart-live-url", + ); fetch(modalHref, { method: "GET", @@ -31,24 +33,44 @@ meetingModal.addEventListener("show.bs.modal", function (event) { 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)") + '

    '; + 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") + '

    '; + const modalEndLink = + '

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

    "; allLinks = modalEndLink; } modalBody.innerHTML = '
    ' + '
    ' + - generateHtml(data.info) + - '
    ' + + generateHtml(data.info) + + "
    " + '
    ' + - allLinks + - '
    ' + - '
    '; + allLinks + + " " + + ""; } }) .catch((error) => { @@ -115,5 +137,5 @@ function copyValue(value) { * Display a loading cursor. */ function displayLoader() { - document.body.style.cursor = 'wait'; + document.body.style.cursor = "wait"; } From e6e11d45bd85b45cb7369e58817c01ed639508b2 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 12 Apr 2024 11:33:19 +0000 Subject: [PATCH 20/37] Fixup. Format code with Black --- pod/main/rest_router.py | 4 +- pod/meeting/forms.py | 8 +- pod/meeting/models.py | 4 +- pod/meeting/rest_views.py | 22 +--- pod/meeting/tests/test_utils.py | 13 ++- pod/meeting/tests/test_views.py | 44 +++---- pod/meeting/views.py | 81 ++++--------- pod/meeting/webinar.py | 201 +++++++++----------------------- pod/meeting/webinar_utils.py | 62 +++++----- 9 files changed, 151 insertions(+), 288 deletions(-) diff --git a/pod/main/rest_router.py b/pod/main/rest_router.py index 6e78f422cf..1fed02eb1f 100644 --- a/pod/main/rest_router.py +++ b/pod/main/rest_router.py @@ -77,7 +77,9 @@ 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_internal_recording", meeting_views.InternalRecordingModelViewSet + ) router.register(r"meeting_livestream", meeting_views.LivestreamModelViewSet) router.register(r"meeting_live_gateway", meeting_views.LiveGatewayModelViewSet) diff --git a/pod/meeting/forms.py b/pod/meeting/forms.py index 9263dca3fd..196358d9bd 100644 --- a/pod/meeting/forms.py +++ b/pod/meeting/forms.py @@ -18,11 +18,7 @@ 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 -) +from pod.meeting.webinar import start_webinar, stop_webinar, toggle_rtmp_gateway __FILEPICKER__ = False @@ -420,7 +416,7 @@ def enable_chat_has_changed(self): toggle_rtmp_gateway(self.instance.id) def __init__(self, *args, **kwargs): - self.request = kwargs.pop('request', None) + self.request = kwargs.pop("request", None) self.is_staff = ( kwargs.pop("is_staff") if "is_staff" in kwargs.keys() else self.is_staff ) diff --git a/pod/meeting/models.py b/pod/meeting/models.py index 782e0a4b7c..fa03fba6f5 100644 --- a/pod/meeting/models.py +++ b/pod/meeting/models.py @@ -1243,9 +1243,7 @@ class Livestream(models.Model): # Meeting meeting = models.ForeignKey( - Meeting, - on_delete=models.CASCADE, - verbose_name=_("Meeting") + Meeting, on_delete=models.CASCADE, verbose_name=_("Meeting") ) # Live status STATUS = ( diff --git a/pod/meeting/rest_views.py b/pod/meeting/rest_views.py index a6c03462b7..eb4d3446b0 100644 --- a/pod/meeting/rest_views.py +++ b/pod/meeting/rest_views.py @@ -1,4 +1,5 @@ """REST API for the Meeting module.""" + from rest_framework import serializers, viewsets from .models import InternalRecording, LiveGateway, Livestream, Meeting @@ -27,14 +28,7 @@ class MeetingModelViewSet(viewsets.ModelViewSet): class InternalRecordingModelSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = InternalRecording - fields = ( - "id", - "name", - "start_at", - "recording_id", - "meeting", - "site_id" - ) + fields = ("id", "name", "start_at", "recording_id", "meeting", "site_id") class InternalRecordingModelViewSet(viewsets.ModelViewSet): @@ -45,14 +39,8 @@ class InternalRecordingModelViewSet(viewsets.ModelViewSet): class LivestreamModelSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Livestream - fields = ( - "id", - "meeting", - "status", - "event", - "live_gateway_id" - ) - filter_fields = ("status") + fields = ("id", "meeting", "status", "event", "live_gateway_id") + filter_fields = "status" class LivestreamModelViewSet(viewsets.ModelViewSet): @@ -70,7 +58,7 @@ class Meta: "broadcaster_id", "site_id", ) - filter_fields = ("site_id") + filter_fields = "site_id" class LiveGatewayModelViewSet(viewsets.ModelViewSet): diff --git a/pod/meeting/tests/test_utils.py b/pod/meeting/tests/test_utils.py index 5055905fd9..6afadfc87e 100644 --- a/pod/meeting/tests/test_utils.py +++ b/pod/meeting/tests/test_utils.py @@ -11,7 +11,7 @@ chat_rtmp_gateway, start_webinar_livestream, stop_webinar_livestream, - toggle_rtmp_gateway + toggle_rtmp_gateway, ) from pod.meeting.webinar_utils import manage_webinar from pod.video.models import Type @@ -34,7 +34,9 @@ def setUp(self): 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.accessgroup_set.add( + AccessGroup.objects.get(code_name="faculty") + ) user_faculty.owner.save() Meeting.objects.create( @@ -63,7 +65,7 @@ def setUp(self): id=1, rtmp_stream_url="rtmp://127.0.0.1:1935/live/sipmediagw", broadcaster=broadcaster, - site=site + site=site, ) print(" ---> SetUp of MeetingTestUtils: OK!") @@ -79,7 +81,10 @@ def test_sipmediagw_commands1(self): 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)) + self.assertTrue( + "&domain=https%3A%2F%2F127.0.0.1%2Fmeeting%2F0001-webinar_faculty" + in str(ve) + ) # Stop try: stop_webinar_livestream(1, True) diff --git a/pod/meeting/tests/test_views.py b/pod/meeting/tests/test_views.py index 1f6223883b..8967528061 100644 --- a/pod/meeting/tests/test_views.py +++ b/pod/meeting/tests/test_views.py @@ -230,22 +230,16 @@ def setUp(self): 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.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 + 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 + id=2, name="webinar_faculty", owner=user_faculty, site=site, is_webinar=True ) user.owner.sites.add(Site.objects.get_current()) @@ -269,7 +263,7 @@ def setUp(self): id=1, rtmp_stream_url="rtmp://localhost:1935/live/sipmediagw", broadcaster=broadcaster, - site=site + site=site, ) print(" ---> SetUp of MeetingWebinarTestView: OK!") @@ -290,7 +284,9 @@ def test_meeting_webinar_add_edit1_get_request(self): 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!") + print( + " ---> test_meeting_webinar_add_edit1_get_request of MeetingWebinarTestView: OK!" + ) def test_meeting_webinar_add_edit2_get_request(self): self.client = Client() @@ -308,7 +304,9 @@ def test_meeting_webinar_add_edit2_get_request(self): 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!") + print( + " ---> test_meeting_webinar_add_edit2_get_request of MeetingWebinarTestView: OK!" + ) def test_meeting_webinar_add1_post_request(self): self.client = Client() @@ -338,7 +336,7 @@ def test_meeting_webinar_add1_post_request(self): "monthly_type": "date_day", "max_participants": 100, "welcome_text": "Hello", - "is_webinar": True + "is_webinar": True, }, follow=True, ) @@ -349,7 +347,9 @@ def test_meeting_webinar_add1_post_request(self): # 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!") + print( + " ---> test_meeting_webinar_add1_post_request of MeetingWebinarTestView: OK!" + ) def test_meeting_webinar_add2_post_request(self): self.client = Client() @@ -379,7 +379,7 @@ def test_meeting_webinar_add2_post_request(self): "monthly_type": "date_day", "max_participants": 100, "welcome_text": "Hello", - "is_webinar": True + "is_webinar": True, }, follow=True, ) @@ -391,7 +391,9 @@ def test_meeting_webinar_add2_post_request(self): # 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!") + print( + " ---> test_meeting_webinar_add2_post_request of MeetingWebinarTestView: OK!" + ) def test_meeting_webinar_edit_post_request(self): self.client = Client() @@ -423,7 +425,7 @@ def test_meeting_webinar_edit_post_request(self): "monthly_type": "date_day", "max_participants": 100, "welcome_text": "Hello", - "is_webinar": True + "is_webinar": True, }, follow=True, ) @@ -432,7 +434,9 @@ def test_meeting_webinar_edit_post_request(self): 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!") + print( + " ---> test_meeting_webinar_edit1_post_request of MeetingWebinarTestView: OK!" + ) class MeetingDeleteTestView(TestCase): diff --git a/pod/meeting/views.py b/pod/meeting/views.py index d59d3a12ab..b3eae84f25 100644 --- a/pod/meeting/views.py +++ b/pod/meeting/views.py @@ -43,11 +43,7 @@ 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 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 @@ -116,14 +112,10 @@ 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") + settings, "MEETING_WEBINAR_AFFILIATION", ("faculty", "employee", "staff") ) MEETING_WEBINAR_GROUP_ADMIN = getattr( - settings, - "MEETING_WEBINAR_GROUP_ADMIN", - "meeting webinar admin" + settings, "MEETING_WEBINAR_GROUP_ADMIN", "meeting webinar admin" ) DEFAULT_EVENT_TYPE_ID = getattr(settings, "DEFAULT_EVENT_TYPE_ID", 1) @@ -150,9 +142,9 @@ def my_meetings(request: WSGIRequest) -> HttpResponse: 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 @@ -173,9 +165,7 @@ 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: @@ -188,7 +178,7 @@ def manage_personal_meeting_room(request: WSGIRequest): 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, ) @@ -242,7 +232,7 @@ def add_or_edit(request: WSGIRequest, meeting_id=None) -> HttpResponse: current_user=request.user, initial={"owner": default_owner}, manage_webinar=manage_webinar, - is_personal=is_personal + is_personal=is_personal, ) if request.method == "POST": @@ -255,7 +245,7 @@ def add_or_edit(request: WSGIRequest, meeting_id=None) -> HttpResponse: current_user=request.user, current_lang=request.LANGUAGE_CODE, manage_webinar=manage_webinar, - is_personal=is_personal + is_personal=is_personal, ) if form.is_valid(): meeting = save_meeting_form(request, form) @@ -280,7 +270,7 @@ def add_or_edit(request: WSGIRequest, meeting_id=None) -> HttpResponse: "start_date_formats": start_date_formats, "page_title": mark_safe(page_title), "manage_webinar": manage_webinar, - "manage_event": manage_event + "manage_event": manage_event, }, ) @@ -310,11 +300,7 @@ def save_meeting_form(request: WSGIRequest, form: MeetingForm) -> Meeting: form.save_m2m() # Manage webinar - if ( - USE_MEETING_WEBINAR - and can_manage_webinar(request.user) - and meeting.is_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) @@ -329,11 +315,13 @@ def save_meeting_form(request: WSGIRequest, form: MeetingForm) -> Meeting: meeting.is_webinar = False meeting.save() display_message_with_icon( - request, messages.ERROR, _( + 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( @@ -423,10 +411,7 @@ def join(request: WSGIRequest, meeting_id: str, direct_access=None) -> HttpRespo def render_show_page( - request: WSGIRequest, - meeting: Meeting, - show_page: bool, - direct_access: bool + 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: @@ -505,9 +490,7 @@ def check_user(request: WSGIRequest) -> HttpResponse: def check_form( - request: WSGIRequest, - meeting: Meeting, - remove_password_in_form: bool + request: WSGIRequest, meeting: Meeting, remove_password_in_form: bool ) -> HttpResponse: """Check form.""" current_user = request.user if request.user.is_authenticated else None @@ -643,11 +626,7 @@ def end(request: WSGIRequest, meeting_id: str) -> HttpResponse: display_message_with_icon(request, messages.ERROR, msg) else: display_message_with_icon( - request, - messages.INFO, - _( - "The meeting was successfully stopped." - ) + request, messages.INFO, _("The meeting was successfully stopped.") ) return redirect(reverse("meeting:my_meetings")) @@ -689,9 +668,7 @@ def get_meeting_info(request: WSGIRequest, meeting_id: str) -> JsonResponse: @login_required(redirect_field_name="referrer") def get_internal_recordings( - request: WSGIRequest, - meeting_id: str, - recording_id=None + request: WSGIRequest, meeting_id: str, recording_id=None ) -> list: """List the internal recordings, depends on parameters (core function). @@ -798,9 +775,7 @@ def internal_recordings(request: WSGIRequest, meeting_id: str) -> HttpResponse: @ensure_csrf_cookie @login_required(redirect_field_name="referrer") def internal_recording( - request: WSGIRequest, - meeting_id: str, - recording_id: str + request: WSGIRequest, meeting_id: str, recording_id: str ) -> HttpResponse: """Get an internal recording, in JSON format (main function). @@ -1226,9 +1201,7 @@ def get_video_url(request: WSGIRequest, meeting_id: str, recording_id: str) -> s @ensure_csrf_cookie @login_required(redirect_field_name="referrer") def upload_internal_recording_to_pod( - request: WSGIRequest, - recording_id: str, - meeting_id: str + request: WSGIRequest, recording_id: str, meeting_id: str ) -> HttpResponse: """Upload internal recording to Pod. @@ -1294,7 +1267,7 @@ def save_internal_recording( recording_id: str, recording_name: str, meeting_id: str, - source_url=None + source_url=None, ): """Save an internal recording in database. @@ -1345,9 +1318,7 @@ def save_internal_recording( def upload_recording_to_pod( - request: WSGIRequest, - record_id: int, - meeting_id=None + request: WSGIRequest, record_id: int, meeting_id=None ) -> bool: """Upload recording to Pod (main function). @@ -1385,9 +1356,7 @@ def upload_recording_to_pod( def upload_bbb_recording_to_pod( - request: WSGIRequest, - record_id: int, - meeting_id: str + request: WSGIRequest, record_id: int, meeting_id: str ) -> bool: """Upload a BBB or video file recording to Pod. diff --git a/pod/meeting/webinar.py b/pod/meeting/webinar.py index e228c6924f..72dce57145 100644 --- a/pod/meeting/webinar.py +++ b/pod/meeting/webinar.py @@ -16,16 +16,10 @@ 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", - "" -) +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", - "" + settings, "MEETING_WEBINAR_SIPMEDIAGW_TOKEN", "" ) log = logging.getLogger("webinar") @@ -45,9 +39,10 @@ def start_webinar(request: WSGIRequest, meet_id: int): tStop.setDaemon(True) tStop.start() display_message_with_icon( - request, messages.INFO, _( - "Webinar mode has been successfully started for “%s” meeting." - ) % (meeting.name) + 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: @@ -55,18 +50,13 @@ def start_webinar(request: WSGIRequest, meet_id: int): toggle_rtmp_gateway(meet_id) except Exception as exc: log.error( - "Error to start webinar mode for “%s” meeting: %s" % ( - meet_id, - str(exc) - ) + "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) - ) + request, + messages.ERROR, + _("Error to start webinar mode for “%s” meeting: %s") + % (meeting.name, str(exc)), ) @@ -80,25 +70,18 @@ def stop_webinar(request: WSGIRequest, meet_id: int): stop_webinar_livestream(meet_id, True) display_message_with_icon( - request, messages.INFO, _( - "Webinar mode has been successfully stopped for “%s” meeting." - ) % (meeting.name) + 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) - ) - ) + 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) - ) + request, + messages.ERROR, + _("Error to stop webinar mode for “%s” meeting: %s") + % (meeting.name, str(exc)), ) @@ -122,10 +105,7 @@ def start_webinar_livestream(pod_host: str, meet_id: int): 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) - ) + "Error to start webinar mode for “%s” meeting: %s" % (meet_id, str(exc)) ) raise ValueError(str(exc)) @@ -133,18 +113,12 @@ def start_webinar_livestream(pod_host: str, meet_id: int): 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" - ) - ) + 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 + 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 @@ -160,17 +134,10 @@ def stop_webinar_livestream(meet_id: int, force: bool): livestream_in_progress.status = 2 livestream_in_progress.save() else: - log.error( - "No livestream object found for webinar id %s" % meet_id - ) + 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) - ) - ) + log.error("Error to stop webinar mode for “%s” meeting: %s" % (meet_id, str(exc))) if force: raise ValueError(str(exc)) @@ -193,20 +160,14 @@ def wait_meeting_is_stopped(meeting: Meeting): 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" - ) + "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" - ) + "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: @@ -239,10 +200,7 @@ def start_rtmp_gateway(pod_host: str, meet_id: int, livestream_id: int): 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() + pod_host, "meeting", meeting.meeting_id, meeting.get_hashkey() ) # Room used (last 10 caracters) room = meeting.get_hashkey()[-10:] @@ -251,35 +209,24 @@ def start_rtmp_gateway(pod_host: str, meet_id: int, livestream_id: int): # 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_url = slash_join(MEETING_WEBINAR_SIPMEDIAGW_URL, "start") # SIPMediaGW start request headers = { - 'Authorization': 'Bearer %s' % MEETING_WEBINAR_SIPMEDIAGW_TOKEN, + "Authorization": "Bearer %s" % MEETING_WEBINAR_SIPMEDIAGW_TOKEN, } params = { - 'room': room, - 'domain': domain, - 'rtmpDst': rtmp_stream_url, + "room": room, + "domain": domain, + "rtmpDst": rtmp_stream_url, } - response = requests.get( - sipmediagw_url, - params=params, - headers=headers, - verify=False - ) + 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 - ) + "start_rtmp_gateway for meeting %s “%s”: %s" + % (meeting.id, meeting.name, response.text) ) if json_response["res"] != "ok": @@ -294,33 +241,22 @@ def stop_rtmp_gateway(meet_id: int): # 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_url = slash_join(MEETING_WEBINAR_SIPMEDIAGW_URL, "stop") # SIPMediaGW stop request headers = { - 'Authorization': 'Bearer %s' % MEETING_WEBINAR_SIPMEDIAGW_TOKEN, + "Authorization": "Bearer %s" % MEETING_WEBINAR_SIPMEDIAGW_TOKEN, } params = { - 'room': room, + "room": room, } - response = requests.get( - sipmediagw_url, - params=params, - headers=headers, - verify=False - ) + 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 - ) + "stop_rtmp_gateway for meeting %s “%s”: %s" + % (meeting.id, meeting.name, response.text) ) if json_response["res"].find("Warning") != -1: @@ -335,25 +271,14 @@ def toggle_rtmp_gateway(meet_id: int): # 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_url = slash_join(MEETING_WEBINAR_SIPMEDIAGW_URL, "chat") # SIPMediaGW toogle request headers = { - 'Authorization': 'Bearer %s' % MEETING_WEBINAR_SIPMEDIAGW_TOKEN, + "Authorization": "Bearer %s" % MEETING_WEBINAR_SIPMEDIAGW_TOKEN, } - params = { - 'room': room, - 'toggle': True - } - response = requests.get( - sipmediagw_url, - params=params, - headers=headers, - verify=False - ) + 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"}) @@ -362,11 +287,8 @@ def toggle_rtmp_gateway(meet_id: int): 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 - ) + "toggle_rtmp_gateway for meeting %s “%s”: %s" + % (meeting.id, meeting.name, message) ) @@ -377,27 +299,18 @@ def chat_rtmp_gateway(meet_id: int, msg: str): # 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_url = slash_join(MEETING_WEBINAR_SIPMEDIAGW_URL, "chat") # SIPMediaGW toogle request headers = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", } # Manage quotes in msg msg = msg.replace("'", "’") msg = msg.replace('"', "’") - json_data = { - 'room': room, - 'msg': msg - } + json_data = {"room": room, "msg": msg} response = requests.post( - sipmediagw_url, - headers=headers, - json=json_data, - verify=False + sipmediagw_url, headers=headers, json=json_data, verify=False ) message = response.text @@ -405,11 +318,7 @@ def chat_rtmp_gateway(meet_id: int, msg: str): json_response = json.loads(response.text) log.info( - "chat_rtmp_gateway for meeting %s “%s”: %s" % ( - meeting.id, - meeting.name, - message - ) + "chat_rtmp_gateway for meeting %s “%s”: %s" % (meeting.id, meeting.name, message) ) if json_response["res"].find("ok") == -1: diff --git a/pod/meeting/webinar_utils.py b/pod/meeting/webinar_utils.py index f6b5817e4e..000d72f1de 100644 --- a/pod/meeting/webinar_utils.py +++ b/pod/meeting/webinar_utils.py @@ -20,7 +20,9 @@ DEFAULT_EVENT_TYPE_ID = getattr(settings, "DEFAULT_EVENT_TYPE_ID", 1) -def search_for_available_livegateway(request: WSGIRequest, meeting: Meeting) -> LiveGateway: # noqa: C901 +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. @@ -38,8 +40,9 @@ def search_for_available_livegateway(request: WSGIRequest, meeting: Meeting) -> Meeting.objects.filter( is_webinar=True, start_at__gte=timezone.now() - timezone.timedelta(hours=5), - site=site - ).exclude(id=meeting.id)) + site=site, + ).exclude(id=meeting.id) + ) nb_webinars = 0 names_webinars = "" meeting_end_date = meeting.start_at + meeting.expected_duration @@ -48,38 +51,32 @@ def search_for_available_livegateway(request: WSGIRequest, meeting: Meeting) -> 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 - ): + 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 - ): + 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 - ): + 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 - ): + 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() + 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() + 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() @@ -93,10 +90,7 @@ def search_for_available_livegateway(request: WSGIRequest, meeting: Meeting) -> def send_email_webinars( - meeting: Meeting, - nb_webinars: int, - nb_live_gateways: int, - names_webinars: str + 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") @@ -115,7 +109,7 @@ def send_email_webinars( meeting.name, meeting.start_at, meeting.start_at + meeting.expected_duration, - names_webinars + names_webinars, ) html_message = _( "

    There are too many webinars (%s) for the number of " @@ -133,12 +127,14 @@ def send_email_webinars( meeting.name, meeting.start_at, meeting.start_at + meeting.expected_duration, - names_webinars + 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 +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: @@ -162,9 +158,7 @@ def manage_webinar(meeting: Meeting, created: bool, live_gateway: LiveGateway): 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.additional_owners.set(meeting.additional_owners.all()) livestream.event.restrict_access_to_groups.set( meeting.restrict_access_to_groups.all() ) @@ -196,9 +190,7 @@ def create_livestream_event(meeting: Meeting, live_gateway: LiveGateway): is_draft=False, is_restricted=meeting.is_restricted, ) - event.restrict_access_to_groups.set( - meeting.restrict_access_to_groups.all() - ) + event.restrict_access_to_groups.set(meeting.restrict_access_to_groups.all()) # Create the livestream Livestream.objects.create( @@ -206,5 +198,5 @@ def create_livestream_event(meeting: Meeting, live_gateway: LiveGateway): # Status : live not started status=0, event=event, - live_gateway=live_gateway + live_gateway=live_gateway, ) From 56264661a737be361900b2c80eaa3648bb3eac18 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 12 Apr 2024 11:33:44 +0000 Subject: [PATCH 21/37] Auto-update configuration files --- CONFIGURATION_FR.md | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/CONFIGURATION_FR.md b/CONFIGURATION_FR.md index fb24a7c5f4..072340edd9 100644 --- a/CONFIGURATION_FR.md +++ b/CONFIGURATION_FR.md @@ -1855,6 +1855,52 @@ Mettre `USE_MEETING` à True pour activer cette application.
    >> Seuls les utilisateurs "staff" pourront éditer les réunions
    + - `USE_MEETING_WEBINAR` + + > valeur par défaut : `False` + + >> Activation du mode Webinaire pour le module des réunions
    + + - `MEETING_WEBINAR_SIPMEDIAGW_URL` + + > valeur par défaut : `` + + >> URL du serveur SIPMediaGW qui gère les webinaires (Ex: https://sipmediagw.univ.fr)
    + + - `MEETING_WEBINAR_SIPMEDIAGW_TOKEN` + + > valeur par défaut : `` + + >> Jeton bearer du serveur SIPMediaGW qui gère les webinaires
    + + - `MEETING_WEBINAR_FIELDS` + + > valeur par défaut : `("is_webinar", "enable_chat")` + + >> 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", + >> ) + >> + >> ``` + + - `MEETING_WEBINAR_AFFILIATION` + + > valeur par défaut : `['faculty', 'employee', 'staff']` + + >> Groupes d’accès ou affiliations des personnes autorisées à créer un webinaire
    + + - `MEETING_WEBINAR_GROUP_ADMIN` + + > valeur par défaut : `webinar admin` + + >> Groupe des personnes autorisées à créer un webinaire
    + - `USE_MEETING` > valeur par défaut : `False` From aeddda2e0489605d7de9f8007c57b118e86c8889 Mon Sep 17 00:00:00 2001 From: Ptitloup Date: Fri, 12 Apr 2024 15:11:43 +0200 Subject: [PATCH 22/37] [DONE] Meeting add stats about meeting session (#1094) * add MeetingSessionLog to store data about session meeting, record data when session start and user join session - shwo result in admin part * add unit test for MeetingSessionLog model * add unit test * add copyt link direct and not * fix unit test * add lang * add pydoc * fix complexity * add required argument --- pod/locale/fr/LC_MESSAGES/django.po | 37 ++++++++ pod/locale/nl/LC_MESSAGES/django.po | 38 ++++++++ pod/meeting/admin.py | 69 +++++++++++++- pod/meeting/models.py | 56 +++++++++++ .../templates/meeting/link_meeting.html | 9 +- pod/meeting/tests/test_models.py | 94 ++++++++++++++++++- pod/meeting/tests/test_views.py | 7 +- pod/meeting/views.py | 28 +++++- pod/meeting/webinar_utils.py | 66 +++++++------ 9 files changed, 365 insertions(+), 39 deletions(-) diff --git a/pod/locale/fr/LC_MESSAGES/django.po b/pod/locale/fr/LC_MESSAGES/django.po index 690bdd3c39..2156671b7f 100644 --- a/pod/locale/fr/LC_MESSAGES/django.po +++ b/pod/locale/fr/LC_MESSAGES/django.po @@ -5400,6 +5400,18 @@ msgstr "rejoindre" msgid "Recurring" msgstr "Récurrence" +#: pod/meeting/admin.py +msgid "Mode insert, nothing to display" +msgstr "Mode insertion, rien à afficher" + +#: pod/meeting/admin.py +msgid "Moderators" +msgstr "Modérateurs" + +#: pod/meeting/admin.py +msgid "Viewers" +msgstr "Spectateurs" + #: pod/meeting/admin.py pod/meeting/forms.py msgid "Webinar options" msgstr "Options du webinaire" @@ -5850,6 +5862,27 @@ msgstr "Impossible d’obtenir les enregistrements de la réunion !" msgid "Unable to delete recording!" msgstr "Impossible de supprimer l’enregistrement !" +#: pod/meeting/models.py +msgid "meeting" +msgstr "reunion" + +#: pod/meeting/models.py +msgid "creator" +msgstr "createur" + +#: pod/meeting/models.py +#, python-format +msgid "Session of the %(meeting_name)s meeting on %(creation_date)s" +msgstr "Session de la reunion %(meeting_name)s le %(creation_date)s" + +#: pod/meeting/models.py +msgid "Meeting session log" +msgstr "Journal des sessions des réunions" + +#: pod/meeting/models.py +msgid "Meeting session logs" +msgstr "Journaux des sessions des réunions" + #: pod/meeting/models.py msgid "Recording ID" msgstr "Identifiant de l’enregistrement" @@ -6196,6 +6229,10 @@ msgstr "S’authentifier" msgid "Copy the direct join link" msgstr "Copiez le lien d’accès direct" +#: pod/meeting/templates/meeting/link_meeting.html +msgid "Copy the join link" +msgstr "Copiez le lien d’accès avec restriction d'accès" + #: pod/meeting/templates/meeting/link_meeting.html msgid "Invite to the meeting" msgstr "Inviter à la réunion" diff --git a/pod/locale/nl/LC_MESSAGES/django.po b/pod/locale/nl/LC_MESSAGES/django.po index b014bb895e..f73880a37d 100644 --- a/pod/locale/nl/LC_MESSAGES/django.po +++ b/pod/locale/nl/LC_MESSAGES/django.po @@ -5128,8 +5128,21 @@ msgstr "" msgid "Recurring" msgstr "" +<<<<<<< HEAD +#: pod/meeting/admin.py +msgid "Mode insert, nothing to display" +msgstr "" + +#: pod/meeting/admin.py +msgid "Moderators" +msgstr "" + +#: pod/meeting/admin.py +msgid "Viewers" +======= #: pod/meeting/admin.py pod/meeting/forms.py msgid "Webinar options" +>>>>>>> 56264661a737be361900b2c80eaa3648bb3eac18 msgstr "" #: pod/meeting/forms.py pod/video/feeds.py pod/video/models.py @@ -5541,6 +5554,27 @@ msgstr "" msgid "Unable to delete recording!" msgstr "" +#: pod/meeting/models.py +msgid "meeting" +msgstr "" + +#: pod/meeting/models.py +msgid "creator" +msgstr "" + +#: pod/meeting/models.py +#, python-format +msgid "Session of the %(meeting_name)s meeting on %(creation_date)s" +msgstr "" + +#: pod/meeting/models.py +msgid "Meeting session log" +msgstr "" + +#: pod/meeting/models.py +msgid "Meeting session logs" +msgstr "" + #: pod/meeting/models.py msgid "Recording ID" msgstr "" @@ -5834,6 +5868,10 @@ msgstr "" msgid "Copy the direct join link" msgstr "" +#: pod/meeting/templates/meeting/link_meeting.html +msgid "Copy the join link" +msgstr "" + #: pod/meeting/templates/meeting/link_meeting.html msgid "Invite to the meeting" msgstr "" diff --git a/pod/meeting/admin.py b/pod/meeting/admin.py index b6eab1c18f..839d8916cc 100644 --- a/pod/meeting/admin.py +++ b/pod/meeting/admin.py @@ -6,7 +6,8 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.html import mark_safe from django.contrib.admin import widgets - +from django.utils.safestring import SafeText +from .models import MeetingSessionLog from .models import Meeting, InternalRecording, Livestream, LiveGateway from .forms import ( MeetingForm, @@ -193,8 +194,69 @@ class InternalRecordingAdmin(admin.ModelAdmin): ] +@admin.register(MeetingSessionLog) +class MeetingSessionLogAdmin(admin.ModelAdmin): + """Administration for BBB session log. + + Args: + admin (ModelAdmin): admin model + """ + list_display = ( + "meeting", + "creation_date", + "creator", + ) + search_fields = [ + "meeting", + "creator", + ] + + def decrypt_mods_as_json(self, obj): + """Decrypt moderators value to json and show it pretty.""" + if not obj: + return _("Mode insert, nothing to display") + moderators = '

    {}
    '.format( + obj.moderators.replace(' ', ' ')) + return SafeText(moderators) + + decrypt_mods_as_json.short_description = _("Moderators") + decrypt_mods_as_json.allow_tags = True + + def decrypt_viewers_as_json(self, obj): + """Decrypt viewers value to json and show it pretty.""" + if not obj: + return _("Mode insert, nothing to display") + viewers = '
    {}
    '.format( + obj.viewers.replace(' ', ' ')) + return SafeText(viewers) + + decrypt_viewers_as_json.short_description = _("Viewers") + decrypt_viewers_as_json.allow_tags = True + + list_filter = ["creation_date"] + readonly_fields = ( + "meeting", + "creation_date", + "creator", + "decrypt_mods_as_json", + "decrypt_viewers_as_json" + ) + + def has_add_permission(self, request): + """ + Check if user had permission to add new log. + Always return false to prevent it. + """ + return False + + @admin.register(Livestream) class LivestreamAdmin(admin.ModelAdmin): + """Administration for BBB live stream. + + Args: + admin (ModelAdmin): admin model + """ list_display = ( "id", "meeting", @@ -216,6 +278,11 @@ class LivestreamAdmin(admin.ModelAdmin): @admin.register(LiveGateway) class LiveGatewayAdmin(admin.ModelAdmin): + """Administration for BBB live gateway. + + Args: + admin (ModelAdmin): admin model + """ list_display = ( "id", "rtmp_stream_url", diff --git a/pod/meeting/models.py b/pod/meeting/models.py index fa03fba6f5..9654055860 100644 --- a/pod/meeting/models.py +++ b/pod/meeting/models.py @@ -4,6 +4,7 @@ import random import requests import os +import json import base64 from datetime import timedelta, datetime as dt @@ -29,6 +30,7 @@ from django.core.validators import MinLengthValidator from django.core.validators import MaxValueValidator from django.core.validators import MinValueValidator +from django.core.serializers.json import DjangoJSONEncoder from django.db.models import F, Q @@ -448,6 +450,9 @@ def start(self): def start_time(self): return self.start_at.time() + def get_current_session(self): + return self.meetingsessionlog_set.first() + def reset_recurrence(self): """ Reset recurrence so everything indicates that the event occurs only once. @@ -1075,6 +1080,57 @@ def default_site_meeting(sender, instance, **kwargs): raise ValueError(_("Start date must be less than recurring until date")) +class MeetingSessionLog(models.Model): + """This model hold information about Big Blue Button session. + + An object is created each time that session of meeting is created. + It store all moderators and viewers connected during the session. + """ + meeting = models.ForeignKey( + Meeting, editable=False, verbose_name=_('meeting'), on_delete=models.CASCADE + ) + creation_date = models.DateTimeField(editable=False, auto_now_add=True) + creator = models.ForeignKey( + User, editable=False, verbose_name=_('creator'), on_delete=models.CASCADE + ) + moderators = models.TextField(editable=False, default=[]) + viewers = models.TextField(editable=False, default=[]) + + def set_moderators(self, lst): + self.moderators = json.dumps( + lst, + sort_keys=True, + indent=1, + cls=DjangoJSONEncoder + ) + + def get_moderators(self): + return json.loads(self.moderators) + + def set_viewers(self, lst): + self.viewers = json.dumps( + lst, + sort_keys=True, + indent=1, + cls=DjangoJSONEncoder + ) + + def get_viewers(self): + return json.loads(self.viewers) + + def __str__(self): + return _("Session of the %(meeting_name)s meeting on %(creation_date)s") % { + 'meeting_name': self.meeting.name, + 'creation_date': self.creation_date, + } + + class Meta: + verbose_name = _("Meeting session log") + verbose_name_plural = _("Meeting session logs") + ordering = ("meeting", "-creation_date",) + get_latest_by = "creation_date" + + class InternalRecording(models.Model): """This model hold information about Big Blue Button recordings. diff --git a/pod/meeting/templates/meeting/link_meeting.html b/pod/meeting/templates/meeting/link_meeting.html index 9cee56e3e1..9a1362dd37 100644 --- a/pod/meeting/templates/meeting/link_meeting.html +++ b/pod/meeting/templates/meeting/link_meeting.html @@ -11,8 +11,13 @@ - - + + + + + diff --git a/pod/meeting/tests/test_models.py b/pod/meeting/tests/test_models.py index b9e1bb720c..4e88ce7c76 100644 --- a/pod/meeting/tests/test_models.py +++ b/pod/meeting/tests/test_models.py @@ -1,14 +1,15 @@ """Tests the models for meeting module.""" import random - -from ..models import Meeting, InternalRecording +import json +from ..models import Meeting, InternalRecording, MeetingSessionLog from datetime import datetime, date from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.core.exceptions import ValidationError from django.test import TestCase from django.template.defaultfilters import slugify +from django.core.serializers.json import DjangoJSONEncoder from pod.authentication.models import AccessGroup from django.utils import timezone @@ -88,7 +89,7 @@ def test_default_attributs(self): self.assertEqual(meeting1.is_running, False) def test_with_attributs(self): - """Check all attributs values passed when creating a meeting""" + """Check all attributs values passed when creating a meeting.""" meeting2 = Meeting.objects.get(id=2) self.assertEqual(meeting2.name, "test2") meeting_id = "%04d-%s" % (meeting2.id, slugify(meeting2.name)) @@ -101,7 +102,7 @@ def test_with_attributs(self): self.assertEqual(meeting2.is_restricted, True) def test_change_attributs(self): - """Change attributs values in a meeting and save it""" + """Change attributs values in a meeting and save it.""" meeting1 = Meeting.objects.get(id=1) self.assertEqual(meeting1.name, "test") meeting_id = "%04d-%s" % (meeting1.id, slugify(meeting1.name)) @@ -813,3 +814,88 @@ def test_delete_object(self): """Delete a recording.""" InternalRecording.objects.filter(name="test recording2").delete() self.assertEqual(InternalRecording.objects.all().count(), 1) + + +class MeetingSessionLogTestCase(TestCase): + """MeetingSessionLog model tests list. + + Args: + TestCase (class): test case + """ + def setUp(self): + self.user = User.objects.create(username="pod") + self.meeting = Meeting.objects.create(id=1, name="test", owner=self.user) + + def test_default_attributs(self): + """Check all attributs default values when creating a MeetingSessionLog.""" + now = datetime.now() + msl = MeetingSessionLog.objects.create(meeting=self.meeting, creator=self.user) + self.assertEqual(msl.meeting, self.meeting) + self.assertEqual(msl.creator, self.user) + self.assertTrue(msl.creation_date > timezone.make_aware(now)) + self.assertTrue(msl.creation_date < timezone.make_aware(datetime.now())) + self.assertEqual(msl.moderators, []) + self.assertEqual(msl.viewers, []) + + def test_with_attributs(self): + """Check all attributs values passed when creating a MeetingSessionLog.""" + now = datetime.now() + msl = MeetingSessionLog.objects.create( + meeting=self.meeting, + creator=self.user, + moderators=[[now, "moderator1"]], + viewers=[[now, "viewer1"]] + ) + self.assertEqual(msl.meeting, self.meeting) + self.assertEqual(msl.creator, self.user) + self.assertEqual(msl.moderators, [[now, "moderator1"]]) + self.assertEqual(msl.viewers, [[now, "viewer1"]]) + + def test_change_attributs(self): + """Change attributs values in a MeetingSessionLog and save it.""" + now = datetime.now() + msl = MeetingSessionLog.objects.create(meeting=self.meeting, creator=self.user) + sess = self.meeting.get_current_session() + self.assertEqual(msl.id, sess.id) + mods = sess.get_moderators() + mods.append([now, "moderator1"]) + sess.set_moderators(mods) + sess.save() + msl.refresh_from_db() + self.assertEqual( + msl.moderators, + json.dumps( + [[now, "moderator1"]], + sort_keys=True, + indent=1, + cls=DjangoJSONEncoder + ) + ) + viewers = sess.get_viewers() + viewers.append([now, "viewer1"]) + sess.set_viewers(viewers) + sess.save() + msl.refresh_from_db() + self.assertEqual( + msl.viewers, + json.dumps( + [[now, "viewer1"]], + sort_keys=True, + indent=1, + cls=DjangoJSONEncoder + ) + ) + + def test_delete_object(self): + """Delete a MeetingSessionLog.""" + self.assertEqual(MeetingSessionLog.objects.all().count(), 0) + msl = MeetingSessionLog.objects.create(meeting=self.meeting, creator=self.user) + self.assertEqual(MeetingSessionLog.objects.all().count(), 1) + msl.delete() + self.assertEqual(MeetingSessionLog.objects.all().count(), 0) + # test delete cascade + msl = MeetingSessionLog.objects.create(meeting=self.meeting, creator=self.user) + self.assertEqual(MeetingSessionLog.objects.all().count(), 1) + self.meeting.delete() + self.assertEqual(MeetingSessionLog.objects.all().count(), 0) + self.assertEqual(Meeting.objects.all().count(), 0) diff --git a/pod/meeting/tests/test_views.py b/pod/meeting/tests/test_views.py index 8967528061..b0915b26c6 100644 --- a/pod/meeting/tests/test_views.py +++ b/pod/meeting/tests/test_views.py @@ -579,6 +579,9 @@ def test_meeting_join_get_request(self): msg_prefix="", fetch_redirect_response=False, ) + sess = newmeeting.get_current_session() + self.assertEqual(len(sess.get_moderators()), 1) + self.assertEqual(sess.get_moderators()[0][1], fullname) # check if meeting is created, try to join it response = requests.get(join_url) self.assertEqual(response.status_code, 200) # OK @@ -607,7 +610,9 @@ def test_meeting_join_get_request(self): msg_prefix="", fetch_redirect_response=False, ) - + sess = newmeeting.get_current_session() + self.assertEqual(len(sess.get_viewers()), 1) + self.assertEqual(sess.get_viewers()[0][1], "anonymous") # Authenticated User --> ask for name and attendee_password self.user2 = User.objects.get(username="pod2") self.client.force_login(self.user2) diff --git a/pod/meeting/views.py b/pod/meeting/views.py index b3eae84f25..06bf3b4e52 100644 --- a/pod/meeting/views.py +++ b/pod/meeting/views.py @@ -11,7 +11,7 @@ from .forms import MeetingForm, MeetingDeleteForm, MeetingPasswordForm from .forms import MeetingInviteForm, get_random_string -from .models import Meeting, InternalRecording, Livestream +from .models import Meeting, InternalRecording, Livestream, MeetingSessionLog from .utils import get_nth_week_number, send_email_recording_ready from datetime import datetime from django.conf import settings @@ -423,6 +423,12 @@ def render_show_page( else request.user.get_username() ) join_url = meeting.get_join_url(fullname, "VIEWER", request.user.get_username()) + # session log + sess = meeting.get_current_session() + viewers = sess.get_viewers() + viewers.append([datetime.now(), fullname]) + sess.set_viewers(viewers) + sess.save() return redirect(join_url) if show_page: remove_password_in_form = direct_access is not None @@ -441,7 +447,11 @@ def render_show_page( def join_as_moderator(request: WSGIRequest, meeting: Meeting) -> HttpResponse: """Join as a moderator.""" try: - created = meeting.create(request) + created = True + if meeting.get_is_meeting_running() is not True: + created = meeting.create(request) + MeetingSessionLog.objects.create(meeting=meeting, creator=request.user) + if created: # get user name and redirect to BBB with moderator rights fullname = ( @@ -452,10 +462,15 @@ def join_as_moderator(request: WSGIRequest, meeting: Meeting) -> HttpResponse: join_url = meeting.get_join_url( fullname, "MODERATOR", request.user.get_username() ) + # session log + sess = meeting.get_current_session() + mods = sess.get_moderators() + mods.append([datetime.now(), fullname]) + sess.set_moderators(mods) + sess.save() # 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 ! " @@ -512,6 +527,7 @@ def check_form( if access_granted: # get user name from form and redirect to BBB join_url = "" + fullname = "" if current_user: fullname = ( request.user.get_full_name() @@ -524,6 +540,12 @@ def check_form( else: fullname = form.cleaned_data["name"] join_url = meeting.get_join_url(fullname, "VIEWER") + # session log + sess = meeting.get_current_session() + viewers = sess.get_viewers() + viewers.append([datetime.now(), fullname]) + sess.set_viewers(viewers) + sess.save() return redirect(join_url) else: display_message_with_icon( diff --git a/pod/meeting/webinar_utils.py b/pod/meeting/webinar_utils.py index 000d72f1de..62aa7c8e13 100644 --- a/pod/meeting/webinar_utils.py +++ b/pod/meeting/webinar_utils.py @@ -20,6 +20,23 @@ DEFAULT_EVENT_TYPE_ID = getattr(settings, "DEFAULT_EVENT_TYPE_ID", 1) +def get_webinar_overlapping(meeting, webinar): + """search if webinar overlapp a meeting.""" + webinar_overlapping = False + meeting_end_date = meeting.start_at + meeting.expected_duration + 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 + return webinar_overlapping + + def search_for_available_livegateway( request: WSGIRequest, meeting: Meeting ) -> LiveGateway: # noqa: C901 @@ -45,21 +62,9 @@ def search_for_available_livegateway( ) 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 - + webinar_overlapping = get_webinar_overlapping(meeting, webinar) if webinar_overlapping: names_webinars += "%s, " % webinar.name nb_webinars += 1 @@ -132,6 +137,25 @@ def send_email_webinars( mail_admins(subject, message, fail_silently=False, html_message=html_message) +def update_livestream_event(livestream, meeting): + """Update event livestream from meeeting attributes.""" + 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() + + def manage_webinar( meeting: Meeting, created: bool, live_gateway: LiveGateway ): # noqa: C901 @@ -150,21 +174,7 @@ def manage_webinar( # 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() + update_livestream_event(livestream, meeting) # When check is_webinar for an existent meeting if meeting.is_webinar and not created and not livestream: From 11e14b660267106bbc17bca2283d02e8ecec0539 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 12 Apr 2024 13:12:18 +0000 Subject: [PATCH 23/37] Fixup. Format code with Black --- pod/meeting/admin.py | 11 ++++++----- pod/meeting/models.py | 28 +++++++++++----------------- pod/meeting/tests/test_models.py | 17 ++++++----------- 3 files changed, 23 insertions(+), 33 deletions(-) diff --git a/pod/meeting/admin.py b/pod/meeting/admin.py index 839d8916cc..d7b6a49b7a 100644 --- a/pod/meeting/admin.py +++ b/pod/meeting/admin.py @@ -201,6 +201,7 @@ class MeetingSessionLogAdmin(admin.ModelAdmin): Args: admin (ModelAdmin): admin model """ + list_display = ( "meeting", "creation_date", @@ -215,8 +216,7 @@ def decrypt_mods_as_json(self, obj): """Decrypt moderators value to json and show it pretty.""" if not obj: return _("Mode insert, nothing to display") - moderators = '
    {}
    '.format( - obj.moderators.replace(' ', ' ')) + moderators = "
    {}
    ".format(obj.moderators.replace(" ", " ")) return SafeText(moderators) decrypt_mods_as_json.short_description = _("Moderators") @@ -226,8 +226,7 @@ def decrypt_viewers_as_json(self, obj): """Decrypt viewers value to json and show it pretty.""" if not obj: return _("Mode insert, nothing to display") - viewers = '
    {}
    '.format( - obj.viewers.replace(' ', ' ')) + viewers = "
    {}
    ".format(obj.viewers.replace(" ", " ")) return SafeText(viewers) decrypt_viewers_as_json.short_description = _("Viewers") @@ -239,7 +238,7 @@ def decrypt_viewers_as_json(self, obj): "creation_date", "creator", "decrypt_mods_as_json", - "decrypt_viewers_as_json" + "decrypt_viewers_as_json", ) def has_add_permission(self, request): @@ -257,6 +256,7 @@ class LivestreamAdmin(admin.ModelAdmin): Args: admin (ModelAdmin): admin model """ + list_display = ( "id", "meeting", @@ -283,6 +283,7 @@ class LiveGatewayAdmin(admin.ModelAdmin): Args: admin (ModelAdmin): admin model """ + list_display = ( "id", "rtmp_stream_url", diff --git a/pod/meeting/models.py b/pod/meeting/models.py index 9654055860..6ac1bf891b 100644 --- a/pod/meeting/models.py +++ b/pod/meeting/models.py @@ -1086,48 +1086,42 @@ class MeetingSessionLog(models.Model): An object is created each time that session of meeting is created. It store all moderators and viewers connected during the session. """ + meeting = models.ForeignKey( - Meeting, editable=False, verbose_name=_('meeting'), on_delete=models.CASCADE + Meeting, editable=False, verbose_name=_("meeting"), on_delete=models.CASCADE ) creation_date = models.DateTimeField(editable=False, auto_now_add=True) creator = models.ForeignKey( - User, editable=False, verbose_name=_('creator'), on_delete=models.CASCADE + User, editable=False, verbose_name=_("creator"), on_delete=models.CASCADE ) moderators = models.TextField(editable=False, default=[]) viewers = models.TextField(editable=False, default=[]) def set_moderators(self, lst): - self.moderators = json.dumps( - lst, - sort_keys=True, - indent=1, - cls=DjangoJSONEncoder - ) + self.moderators = json.dumps(lst, sort_keys=True, indent=1, cls=DjangoJSONEncoder) def get_moderators(self): return json.loads(self.moderators) def set_viewers(self, lst): - self.viewers = json.dumps( - lst, - sort_keys=True, - indent=1, - cls=DjangoJSONEncoder - ) + self.viewers = json.dumps(lst, sort_keys=True, indent=1, cls=DjangoJSONEncoder) def get_viewers(self): return json.loads(self.viewers) def __str__(self): return _("Session of the %(meeting_name)s meeting on %(creation_date)s") % { - 'meeting_name': self.meeting.name, - 'creation_date': self.creation_date, + "meeting_name": self.meeting.name, + "creation_date": self.creation_date, } class Meta: verbose_name = _("Meeting session log") verbose_name_plural = _("Meeting session logs") - ordering = ("meeting", "-creation_date",) + ordering = ( + "meeting", + "-creation_date", + ) get_latest_by = "creation_date" diff --git a/pod/meeting/tests/test_models.py b/pod/meeting/tests/test_models.py index 4e88ce7c76..143442f7fe 100644 --- a/pod/meeting/tests/test_models.py +++ b/pod/meeting/tests/test_models.py @@ -822,6 +822,7 @@ class MeetingSessionLogTestCase(TestCase): Args: TestCase (class): test case """ + def setUp(self): self.user = User.objects.create(username="pod") self.meeting = Meeting.objects.create(id=1, name="test", owner=self.user) @@ -844,7 +845,7 @@ def test_with_attributs(self): meeting=self.meeting, creator=self.user, moderators=[[now, "moderator1"]], - viewers=[[now, "viewer1"]] + viewers=[[now, "viewer1"]], ) self.assertEqual(msl.meeting, self.meeting) self.assertEqual(msl.creator, self.user) @@ -865,11 +866,8 @@ def test_change_attributs(self): self.assertEqual( msl.moderators, json.dumps( - [[now, "moderator1"]], - sort_keys=True, - indent=1, - cls=DjangoJSONEncoder - ) + [[now, "moderator1"]], sort_keys=True, indent=1, cls=DjangoJSONEncoder + ), ) viewers = sess.get_viewers() viewers.append([now, "viewer1"]) @@ -879,11 +877,8 @@ def test_change_attributs(self): self.assertEqual( msl.viewers, json.dumps( - [[now, "viewer1"]], - sort_keys=True, - indent=1, - cls=DjangoJSONEncoder - ) + [[now, "viewer1"]], sort_keys=True, indent=1, cls=DjangoJSONEncoder + ), ) def test_delete_object(self): From 305875c013dc1475fbc2aa7643a4ed0409533425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Coze?= <96086580+SebastienCozeDev@users.noreply.github.com> Date: Mon, 15 Apr 2024 12:04:04 +0200 Subject: [PATCH 24/37] [DONE] Add settings and fix bugs (#1098) * :bug: Add access for unconnected user to the playlist content page * :shirt: Simplify render_playlist function * :bug: Add the access to the promoted playlist for the unconnected users * Add USE_PROMOTED_PLAYLIST setting * Add the RESTRICT_PROMOTED_PLAYLIST_ACCESS_TO_STAFF_ONLY setting * Add documentation for new settings * Fix some problem for the unit tests * Remove unnecesary declaration * :bug: Fix the title size bug * Add translation * update translation --------- Co-authored-by: Ptitloup --- pod/locale/fr/LC_MESSAGES/django.po | 68 +++----------- pod/locale/fr/LC_MESSAGES/djangojs.po | 2 +- pod/locale/nl/LC_MESSAGES/django.po | 92 +++++++++++++++++-- pod/locale/nl/LC_MESSAGES/djangojs.po | 2 +- pod/main/configuration.json | 26 ++++++ pod/main/templates/navbar.html | 2 +- pod/main/test_settings.py | 2 + pod/playlist/context_processors.py | 2 + pod/playlist/forms.py | 49 +++++++--- pod/playlist/models.py | 8 +- .../templates/playlist/filter_aside.html | 10 +- .../templates/playlist/playlists.html | 14 ++- pod/playlist/views.py | 60 ++++++++---- 13 files changed, 229 insertions(+), 108 deletions(-) diff --git a/pod/locale/fr/LC_MESSAGES/django.po b/pod/locale/fr/LC_MESSAGES/django.po index 2156671b7f..88ff434d39 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-12 12:54+0200\n" +"POT-Creation-Date: 2024-04-15 09:44+0000\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -5400,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/admin.py msgid "Mode insert, nothing to display" msgstr "Mode insertion, rien à afficher" @@ -5412,10 +5416,6 @@ msgstr "Modérateurs" msgid "Viewers" msgstr "Spectateurs" -#: 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 @@ -6650,7 +6650,7 @@ msgstr "Informations générales" msgid "Security informations" msgstr "Informations de sécurité" -#: pod/playlist/forms.py pod/playlist/models.py +#: pod/playlist/forms.py msgid "Please choose a title between 1 and 250 characters." msgstr "Veuillez entrer un titre contenant entre 1 et 250 caractères." @@ -6739,6 +6739,15 @@ msgstr "Protégé par un mot de passe" msgid "Private" msgstr "Privé" +#: pod/playlist/models.py +#, python-brace-format +msgid "" +"Please choose a title between 1 and {__MAX_LENGTH_FOR_PLAYLIST_NAME__} " +"characters." +msgstr "" +"Veuillez entrer un titre contenant entre 1 et " +"{__MAX_LENGTH_FOR_PLAYLIST_NAME__} caractères." + #: pod/playlist/models.py msgid "" "Selecting this setting causes your playlist to be promoted on the page " @@ -9905,50 +9914,3 @@ msgstr "Résultats de la recherche" #: pod/xapi/apps.py msgid "Esup-Pod xAPI" msgstr "xAPI Esup-Pod" - -#~ msgid "Previous" -#~ msgstr "Vignette précédente" - -#~ msgid "Next" -#~ msgstr "Vignette suivante" - -#~ msgid "" -#~ "Pod is aimed at users of our institutions, by allowing the publication of " -#~ "videos in the fields of research (promotion of platforms, etc.), training " -#~ "(tutorials, distance training, student reports, etc.), institutional life " -#~ "(video of events), offering several days of content." -#~ msgstr "" -#~ "Pod a pour but de faciliter la mise à disposition de vidéos et de ce " -#~ "fait, d’encourager l’utilisation de celles-ci dans le cadre de " -#~ "l’enseignement et la recherche." - -#~ msgid "The HTML file for this recording was not found on the server." -#~ msgstr "" -#~ "Le fichier HTML de cet enregistrement est introuvable sur le serveur." - -#~ msgid "Error number: %s" -#~ msgstr "Numéro d’erreur : %s" - -#~ msgid "Playlist image" -#~ msgstr "Image de la playlist" - -#~ msgid "Live" -#~ msgstr "Directs" - -#~ msgid "Deleting the event \"%(vtitle)s\"" -#~ msgstr "Supprimer l’évènement « %(vtitle)s »" - -#~ msgid "To delete the event, please checked in and click send." -#~ msgstr "Pour supprimer l’évènement, veuillez cocher et cliquer sur envoyer." - -#~ msgid "To delete the record, please checked in and click send." -#~ msgstr "Pour supprimer la vidéo, veuillez cocher et cliquer sur envoyer." - -#~ msgid "To delete the video, please checked in and click send." -#~ msgstr "Pour supprimer la vidéo, veuillez cocher et cliquer sur envoyer." - -#~ msgid "Unable to find information about the meeting" -#~ msgstr "Impossible de trouver des informations sur la réunion" - -#~ msgid "End the meeting" -#~ msgstr "Terminer la réunion" diff --git a/pod/locale/fr/LC_MESSAGES/djangojs.po b/pod/locale/fr/LC_MESSAGES/djangojs.po index 72509887db..41ef5c1c9d 100644 --- a/pod/locale/fr/LC_MESSAGES/djangojs.po +++ b/pod/locale/fr/LC_MESSAGES/djangojs.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Esup-Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-12 12:54+0200\n" +"POT-Creation-Date: 2024-04-15 09:44+0000\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: \n" diff --git a/pod/locale/nl/LC_MESSAGES/django.po b/pod/locale/nl/LC_MESSAGES/django.po index f73880a37d..45117705db 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-12 12:54+0200\n" +"POT-Creation-Date: 2024-04-15 09:44+0000\n" "PO-Revision-Date: 2023-06-08 14:37+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -4647,8 +4647,10 @@ msgid "Write in html inside this field." msgstr "" #: pod/main/models.py +#, fuzzy +#| msgid "Filter by title" msgid "Display title" -msgstr "" +msgstr "Filter op titel" #: pod/main/models.py msgid "No cache" @@ -5128,7 +5130,10 @@ msgstr "" msgid "Recurring" msgstr "" -<<<<<<< HEAD +#: pod/meeting/admin.py pod/meeting/forms.py +msgid "Webinar options" +msgstr "" + #: pod/meeting/admin.py msgid "Mode insert, nothing to display" msgstr "" @@ -5139,10 +5144,6 @@ msgstr "" #: pod/meeting/admin.py msgid "Viewers" -======= -#: pod/meeting/admin.py pod/meeting/forms.py -msgid "Webinar options" ->>>>>>> 56264661a737be361900b2c80eaa3648bb3eac18 msgstr "" #: pod/meeting/forms.py pod/video/feeds.py pod/video/models.py @@ -6223,7 +6224,7 @@ msgstr "" msgid "Security informations" msgstr "" -#: pod/playlist/forms.py pod/playlist/models.py +#: pod/playlist/forms.py msgid "Please choose a title between 1 and 250 characters." msgstr "" @@ -6299,6 +6300,13 @@ msgstr "" msgid "Private" msgstr "" +#: pod/playlist/models.py +#, python-brace-format +msgid "" +"Please choose a title between 1 and {__MAX_LENGTH_FOR_PLAYLIST_NAME__} " +"characters." +msgstr "" + #: pod/playlist/models.py msgid "" "Selecting this setting causes your playlist to be promoted on the page " @@ -9259,3 +9267,71 @@ msgstr "" #: pod/xapi/apps.py msgid "Esup-Pod xAPI" msgstr "" + +#, fuzzy +#~| msgid "Please enter a valid address" +#~ msgid "Enter a valid value." +#~ msgstr "Vul alstublieft een geldig adres in" + +#, fuzzy +#~| msgid "Please enter a valid address" +#~ msgid "Enter a valid email address." +#~ msgstr "Vul alstublieft een geldig adres in" + +#, fuzzy +#~| msgid "Please enter a valid address" +#~ msgid "Enter a valid IPv4 address." +#~ msgstr "Vul alstublieft een geldig adres in" + +#, fuzzy +#~| msgid "Please enter a valid address" +#~ msgid "Enter a valid IPv6 address." +#~ msgstr "Vul alstublieft een geldig adres in" + +#, fuzzy +#~| msgid "Please enter a valid address" +#~ msgid "Enter a valid IPv4 or IPv6 address." +#~ msgstr "Vul alstublieft een geldig adres in" + +#, fuzzy +#~| msgid "Please enter a valid address" +#~ msgid "Enter a valid date." +#~ msgstr "Vul alstublieft een geldig adres in" + +#, fuzzy +#~| msgid "member" +#~ msgid "September" +#~ msgstr "lid" + +#, fuzzy +#~| msgid "member" +#~ msgid "November" +#~ msgstr "lid" + +#, fuzzy +#~| msgid "member" +#~ msgid "December" +#~ msgstr "lid" + +#, fuzzy +#~| msgid "member" +#~ msgctxt "alt. month" +#~ msgid "September" +#~ msgstr "lid" + +#, fuzzy +#~| msgid "member" +#~ msgctxt "alt. month" +#~ msgid "November" +#~ msgstr "lid" + +#, fuzzy +#~| msgid "member" +#~ msgctxt "alt. month" +#~ msgid "December" +#~ msgstr "lid" + +#, fuzzy +#~| msgid "Please enter a valid address" +#~ msgid "This is not a valid IPv6 address." +#~ msgstr "Vul alstublieft een geldig adres in" diff --git a/pod/locale/nl/LC_MESSAGES/djangojs.po b/pod/locale/nl/LC_MESSAGES/djangojs.po index 97e27670b4..b537b6f0ef 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-12 12:54+0200\n" +"POT-Creation-Date: 2024-04-15 09:44+0000\n" "PO-Revision-Date: 2023-02-08 15:22+0100\n" "Last-Translator: obado \n" "Language-Team: \n" diff --git a/pod/main/configuration.json b/pod/main/configuration.json index 869094cb43..b4c92a72f3 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -1839,6 +1839,19 @@ "pod_version_end": "", "pod_version_init": "3.4" }, + "RESTRICT_PROMOTED_PLAYLIST_ACCESS_TO_STAFF_ONLY": { + "default_value": true, + "description": { + "en": [ + "Restrict access to promoted playlists creation to staff only." + ], + "fr": [ + "Restreindre l’accès à la création de listes de lecture promues au staff uniquement." + ] + }, + "pod_version_end": "", + "pod_version_init": "3.6" + }, "USE_FAVORITES": { "default_value": true, "description": { @@ -1864,6 +1877,19 @@ }, "pod_version_end": "", "pod_version_init": "3.4" + }, + "USE_PROMOTED_PLAYLIST": { + "default_value": true, + "description": { + "en": [ + "Activation of promoted playlists. Allows users to use the promoted playlists." + ], + "fr": [ + "Activation des playlist promues. Permet aux utilisateurs d'utiliser les listes de lecture promues." + ] + }, + "pod_version_end": "", + "pod_version_init": "3.6" } }, "title": { diff --git a/pod/main/templates/navbar.html b/pod/main/templates/navbar.html index e35cfc35be..53d339e57d 100644 --- a/pod/main/templates/navbar.html +++ b/pod/main/templates/navbar.html @@ -54,7 +54,7 @@
    {% trans "Main menu" %} {% endif %} - {% if USE_PLAYLIST %} + {% if USE_PLAYLIST and USE_PROMOTED_PLAYLIST %} {% endblock %} {% block page_content %} + {% if video.encoding_in_progress %} + + {% endif %} -{% if video.encoding_in_progress %} - -{% endif %} - - - {% include 'videos/video-element.html' %} - + + {% include 'videos/video-element.html' %} + - - {% csrf_token %} -
    - {% trans 'Pick a dressing below' %} - - - - - - - - - - - - - - - {% for dressing in dressings %} + + {% csrf_token %} +
    + {% trans 'Pick a dressing below' %} +
    {% trans 'Available video dressings' %}
    {% trans 'Select' %}{% trans 'Title' %}{% trans 'Watermark' %}{% trans 'Position' %}{% trans 'Opacity' %}{% trans 'Opening credits' %}{% trans 'Ending credits' %}
    + + - - - - - + + + + + + + - {% empty %} - - - - {% endfor %} - -
    {% trans 'Available video dressings' %}
    - - - {% if dressing.watermark %} - {{ dressing.watermark.name }} - {% else %} - {% trans 'None' %} - {% endif %} - - {% if dressing.watermark %} - {{ dressing.get_position_display }} - {% else %} -
    - -
    - {% endif %} -
    - {% if dressing.watermark %} - {{ dressing.opacity }} - {% else %} -
    - -
    - {% endif %} -
    - {% if dressing.opening_credits %} - {{ dressing.opening_credits }} - {% else %} - {% trans 'None' %} - {% endif %} - - {% if dressing.ending_credits %} - {{ dressing.ending_credits }} - {% else %} - {% trans 'None' %} - {% endif %} - {% trans 'Select' %}{% trans 'Title' %}{% trans 'Watermark' %}{% trans 'Position' %}{% trans 'Opacity' %}{% trans 'Opening credits' %}{% trans 'Ending credits' %}
    {% trans 'No dressings found.' %}
    -
    - - - - - -  {% trans 'My dressings' %} - -  {% trans 'Back to the video' %} - - + + + {% for dressing in dressings %} + + + + + + + {% if dressing.watermark %} + {{ dressing.watermark.name }} + {% else %} + {% trans 'None' %} + {% endif %} + + + {% if dressing.watermark %} + {{ dressing.get_position_display }} + {% else %} +
    +   +
    + {% endif %} + + + {% if dressing.watermark %} + {{ dressing.opacity }} + {% else %} +
    +   +
    + {% endif %} + + + {% if dressing.opening_credits %} + {{ dressing.opening_credits }} + {% else %} + {% trans 'None' %} + {% endif %} + + + {% if dressing.ending_credits %} + {{ dressing.ending_credits }} + {% else %} + {% trans 'None' %} + {% endif %} + + + {% empty %} + + {% trans 'No dressings found.' %} + + {% endfor %} + + + + + + + +  {% trans 'My dressings' %} + +  {% trans 'Back to the video' %} + + {% endblock page_content %} {% block page_aside %} - {% if access_not_allowed == True %} - {% else %} -
    -

     {% trans "Manage video" %}

    -
    - {% include "videos/link_video.html" with hide_favorite_link=True %} -
    +
    +

     {% trans "Manage video" %}

    +
    + {% include "videos/link_video.html" with hide_favorite_link=True %}
    - {% endif %} +
    + +
    +

    {% trans "Help"%}

    + +
    +

    + {% trans "Dressings are a way to customize your video. You can add a watermark, opening and ending credits" %} +

    +
    + + +
    +

    + {% trans "A watermark is an image that will be displayed on the video. You can choose the position and the opacity of the watermark." %} +

    +
    + + +
    +

    + {% trans "Opening and ending credits are videos that will be displayed at the beginning or at the end of the video." %} +

    +
    +
    {% endblock page_aside %} {% block more_script %} diff --git a/pod/dressing/tests/test_views.py b/pod/dressing/tests/test_views.py index 8fdd78855a..ca32b15aa8 100644 --- a/pod/dressing/tests/test_views.py +++ b/pod/dressing/tests/test_views.py @@ -61,6 +61,43 @@ def test_video_dressing_page(self): self.assertTemplateUsed(response, "video_dressing.html") print(" ---> test_video_dressing_page ok") + def test_video_encoding_in_progress(self): + """Test video encoding in progress in VideoDressingViewTest.""" + self.first_video.encoding_in_progress = True + self.first_video.save() + self.client.force_login(self.user) + response = self.client.get( + reverse("dressing:video_dressing", args=[self.first_video.slug]) + ) + messages = [m for m in get_messages(response.wsgi_request)] + + self.assertEqual(response.status_code, 403) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].tags, "error") + self.assertEqual( + messages[0].message, _("The video is currently being encoded.") + ) + print(" ---> test_video_encoding_in_progress ok") + + def test_video_dressing_permission_denied(self): + """Test test_video_dressing_permission_denied in VideoDressingViewTest.""" + user_without_permission = User.objects.create_user( + username="useless_user", password="testpass" + ) + self.client.force_login(user_without_permission) + response = self.client.get( + reverse("dressing:video_dressing", args=[self.first_video.slug]) + ) + messages = [m for m in get_messages(response.wsgi_request)] + + self.assertEqual(response.status_code, 403) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].tags, "error") + self.assertEqual( + messages[0].message, _("You cannot dress this video.") + ) + print(" ---> test_video_dressing_permission_denied ok") + class MyDressingViewTest(TestCase): """My dressing page tests case.""" @@ -109,6 +146,23 @@ def test_my_dressing_page(self): self.assertTemplateUsed(response, "my_dressings.html") print(" ---> test_my_dressing_page ok") + def test_my_dressing_permission_denied(self): + """Test test_my_dressing_permission_denied in MyDressingViewTest.""" + user_without_permission = User.objects.create_user( + username="useless_user", password="testpass" + ) + self.client.force_login(user_without_permission) + response = self.client.get(reverse("dressing:my_dressings")) + messages = [m for m in get_messages(response.wsgi_request)] + + self.assertEqual(response.status_code, 403) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].tags, "error") + self.assertEqual( + messages[0].message, _("You cannot access this page.") + ) + print(" ---> test_my_dressing_permission_denied ok") + class DressingEditViewTest(TestCase): """Dressing edit page tests case.""" @@ -180,6 +234,22 @@ def setUp(self) -> None: self.dressing.users.set([self.user]) self.dressing.videos.set([self.first_video]) + def test_maintenance(self): + """Test Pod maintenance mode in VideoDressingViewTest.""" + self.client.force_login(self.user) + url = reverse("dressing:dressing_delete", args=[self.dressing.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + conf = Configuration.objects.get(key="maintenance_mode") + conf.value = "1" + conf.save() + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, "/maintenance/") + print(" ---> test_maintenance ok") + def test_dressing_delete_view_get(self): """Test test_dressing_delete_view_get.""" self.client.force_login(self.user) diff --git a/pod/dressing/views.py b/pod/dressing/views.py index c7e65a274b..cfbfa415ba 100644 --- a/pod/dressing/views.py +++ b/pod/dressing/views.py @@ -25,6 +25,7 @@ def video_dressing(request, slug): return redirect(reverse("maintenance")) video = get_object_or_404(Video, slug=slug, sites=get_current_site(request)) + if not video.encoded and video.encoding_in_progress is True: messages.add_message( request, messages.ERROR, _("The video is currently being encoded.") @@ -36,7 +37,6 @@ def video_dressing(request, slug): if not ( request.user.is_superuser or request.user.is_staff - or request.user.has_perm("dressing.video_dressing") ): messages.add_message(request, messages.ERROR, _("You cannot dress this video.")) raise PermissionDenied @@ -67,10 +67,10 @@ def video_dressing(request, slug): request, "video_dressing.html", { + "page_title": _("Dress the video “%s”") % video.title, "video": video, "dressings": dressings, "current": current, - "page_title": _("Dress the video “%s”") % video.title, }, ) @@ -85,7 +85,6 @@ def dressing_edit(request, dressing_id): not ( request.user.is_superuser or request.user.is_staff - or request.user.has_perm("dressing.dressing_edit") ) ): messages.add_message(request, messages.ERROR, _("You cannot edit this dressing.")) @@ -110,14 +109,14 @@ def dressing_edit(request, dressing_id): ) form_dressing.save() return redirect(reverse("dressing:my_dressings")) - page_title = f'{_("Editing the dressing")} "{dressing_edit.title}"' + page_title = _("Edit the dressing “%s”") % dressing_edit.title return render( request, "dressing_edit.html", { + "page_title": page_title, "dressing_edit": dressing_edit, "form": form_dressing, - "page_title": page_title, }, ) @@ -129,7 +128,6 @@ def dressing_create(request): if not ( request.user.is_superuser or request.user.is_staff - or request.user.has_perm("dressing.my_dressings") ): messages.add_message( request, messages.ERROR, _("You cannot create a video dressing.") @@ -148,8 +146,8 @@ def dressing_create(request): request, "dressing_edit.html", { - "dressing_create": dressing_create, "page_title": _("Create a new dressing"), + "dressing_create": dressing_create, "form": form_dressing, }, ) @@ -160,12 +158,13 @@ def dressing_create(request): def dressing_delete(request, dressing_id): """Delete the dressing identified by 'id'.""" dressing = get_object_or_404(Dressing, id=dressing_id) + if in_maintenance(): + return redirect(reverse("maintenance")) if dressing and ( not ( request.user.is_superuser or request.user.is_staff - or request.user.has_perm("dressing.dressing_delete") ) ): messages.add_message( @@ -194,9 +193,9 @@ def dressing_delete(request, dressing_id): request, "dressing_delete.html", { + "page_title": _("Deleting the dressing “%s”") % dressing.title, "dressing": dressing, "form": form, - "page_title": _("Deleting the dressing “%s”") % dressing.title, }, ) @@ -205,19 +204,22 @@ def dressing_delete(request, dressing_id): @login_required(redirect_field_name="referrer") def my_dressings(request): """Render the logged user's dressings.""" + if in_maintenance(): + return redirect(reverse("maintenance")) + if not ( request.user.is_superuser or request.user.is_staff - or request.user.has_perm("dressing.my_dressings") ): messages.add_message(request, messages.ERROR, _("You cannot access this page.")) raise PermissionDenied - if in_maintenance(): - return redirect(reverse("maintenance")) - dressings = get_dressings(request.user, request.user.owner.accessgroup_set.all()) + dressings = get_dressings(request.user, request.user.owner.accessgroup_set.all()) return render( request, "my_dressings.html", - {"dressings": dressings, "page_title": _("My dressings")}, + { + "page_title": _("My dressings"), + "dressings": dressings, + }, ) diff --git a/pod/import_video/views.py b/pod/import_video/views.py index c312bd663c..1e045a27a0 100644 --- a/pod/import_video/views.py +++ b/pod/import_video/views.py @@ -287,7 +287,7 @@ def add_or_edit_external_recording(request, id=None): if form.is_valid(): recording = save_recording_form(request, form) display_message_with_icon( - request, messages.INFO, _("The changes have been saved.") + request, messages.SUCCESS, _("The changes have been saved.") ) return redirect(reverse("import_video:external_recordings")) else: @@ -348,7 +348,7 @@ def delete_external_recording(request, id: int): msg += args["message"] if delete and msg == "": msg += _("The external recording has been deleted.") - display_message_with_icon(request, messages.INFO, msg) + display_message_with_icon(request, messages.SUCCESS, msg) else: display_message_with_icon(request, messages.ERROR, msg) return redirect(reverse("import_video:external_recordings", args=())) diff --git a/pod/live/templates/live/event-info.html b/pod/live/templates/live/event-info.html index 8bf28ec9b1..9d4f44fdbb 100644 --- a/pod/live/templates/live/event-info.html +++ b/pod/live/templates/live/event-info.html @@ -93,19 +93,16 @@
    diff --git a/pod/main/templates/navbar.html b/pod/main/templates/navbar.html index 53d339e57d..f3e0526a7f 100644 --- a/pod/main/templates/navbar.html +++ b/pod/main/templates/navbar.html @@ -202,7 +202,7 @@
    {% trans 'Configuration' %}
    {% if user.is_authenticated %} - {% endif %} + +
      + {% for vid in video.get_video_mp4 %} + {% if vid.source_file|file_exists %} +
    • +
      + {% csrf_token %} + + +
      +
    • + {% endif %} {% endfor %}
    - - + {% if video.get_video_mp3 %} {% trans 'Audio file:' %}
      @@ -275,173 +278,180 @@

    {% endif %} - - {% endif %} - - {% if video.document_set.all %} - {% trans 'Document:' %} -
      - {% for doc in video.document_set.all %} - {% if request.user.is_superuser or not doc.private %} -
    • -
      - {% csrf_token %} - - - {% if request.user.is_superuser %} - {% if doc.private %} - - {% else %} - - {% endif %} + {% if video.document_set.all %} + +  {% trans 'Document:' %} + +
        + {% for doc in video.document_set.all %} + {% if request.user.is_superuser or not doc.private %} +
      • + + {% csrf_token %} + + + {% if request.user.is_superuser %} + {% if doc.private %} + + {% else %} + {% endif %} -
      • - - {% endif %} - {% endfor%} -
      - {% endif %} - + {% endif %} + +
    • + {% endif %} + {% endfor%} +
    + {% endif %} -{% if video.is_draft == False or video.owner == request.user or request.user in video.additional_owners.all%} -