From 46941e1c4dfd9d8c1857420a45c4e72f0b6cbec2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 10:08:39 +0200 Subject: [PATCH 01/24] Bump urllib3 from 1.26.18 to 1.26.19 (#1164) Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.18 to 1.26.19. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/1.26.19/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.18...1.26.19) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: Ptitloup Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2c82f19d2a..b67ad8056b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ django-modeltranslation==0.18.7 django-cas-client==1.5.3 ldap3==2.9 django-simple-captcha==0.5.20 -urllib3==1.26.18 +urllib3==1.26.19 elasticsearch==6.3.1 djangorestframework==3.14.0 django-filter==22.1 From c59c7b4b2a9d7dd5ae0511f12302a940328db5c1 Mon Sep 17 00:00:00 2001 From: Olivier Bado-Faustin Date: Fri, 28 Jun 2024 15:28:54 +0200 Subject: [PATCH 02/24] [DONE] Fix additional owner rights (#1167) * First try to fix the additional owners bug: Add 2 funcs : and * Add Unit tests * Clean duplicated UserFolders * Flake8 compliance --- README.md | 1 - .../static/ai_enhancement/js/enrich-form.js | 8 +- pod/ai_enhancement/views.py | 6 +- pod/completion/tests/test_models.py | 50 +++---- pod/completion/views.py | 10 +- pod/cut/tests/test_views.py | 21 ++- pod/enrichment/models.py | 12 +- pod/main/configuration.json | 8 +- pod/main/static/js/main.js | 3 +- .../templatetags/flat_page_edito_filter.py | 4 +- pod/podfile/forms.py | 6 +- pod/podfile/models.py | 11 ++ pod/podfile/rest_views.py | 2 + pod/podfile/static/podfile/js/filewidget.js | 64 ++++---- .../templates/podfile/list_folder_files.html | 5 +- pod/podfile/templates/podfile/userfolder.html | 4 +- pod/podfile/tests/test_models.py | 26 ++-- pod/podfile/tests/test_views.py | 15 +- pod/podfile/utils.py | 2 + pod/podfile/views.py | 3 +- pod/quiz/README.md | 2 +- pod/recorder/plugins/type_audiovideocast.py | 8 +- .../management/commands/clean_video_files.py | 88 ++++++++++- pod/video/management/commands/import_data.py | 12 +- pod/video/models.py | 35 +++++ .../templates/videos/video_page_content.html | 5 + pod/video/tests/test_models.py | 141 +++++++++++++----- .../Encoding_video_model.py | 35 ++--- pod/video_encode_transcript/transcript.py | 5 +- 29 files changed, 385 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index 07558ba3b3..c5073652cc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ ![last commit push](https://img.shields.io/github/last-commit/EsupPortail/Esup-Pod) [![Author](https://img.shields.io/badge/author-Ptitloup-blue)](https://www.linkedin.com/in/nicolas-can-a6bb7869/) - ## [FR] ### Plateforme de gestion de fichier vidéo diff --git a/pod/ai_enhancement/static/ai_enhancement/js/enrich-form.js b/pod/ai_enhancement/static/ai_enhancement/js/enrich-form.js index c43a253a55..234989ef42 100644 --- a/pod/ai_enhancement/static/ai_enhancement/js/enrich-form.js +++ b/pod/ai_enhancement/static/ai_enhancement/js/enrich-form.js @@ -4,6 +4,8 @@ * @since 3.7.0 */ +// Read-only globals defined in main.js +/* global decodeString remove_quotes removeAccentsAndLowerCase */ const BORDER_CLASS = 'border-d'; @@ -92,7 +94,7 @@ function addTogglePairInput(aiVersionElement, initialVersionElement, input, elem aiVersionElement.addEventListener('click', () => { let input = document.getElementById('id_' + element); togglePairInput(aiVersionElement, initialVersionElement, input, element); - }) + }); input.addEventListener('input', () => event__inputChange(initialVersionElement, aiVersionElement)); } @@ -182,7 +184,7 @@ function addEventListeners(videoSlug, videoTitle, videoDescription, videoDiscipl 'description', 'tags', 'disciplines', - ] + ]; const options = { method: 'GET', headers: { @@ -227,7 +229,7 @@ function addEventListeners(videoSlug, videoTitle, videoDescription, videoDiscipl }); aiVersionElement.addEventListener('click', () => { toggleMultiplePairInput(aiVersionElement, initialVersionElement, input); - }) + }); input.addEventListener('input', () => event__inputChange(initialVersionElement, aiVersionElement)); break; } diff --git a/pod/ai_enhancement/views.py b/pod/ai_enhancement/views.py index 26da7b90d2..e2d8de01d2 100644 --- a/pod/ai_enhancement/views.py +++ b/pod/ai_enhancement/views.py @@ -25,7 +25,6 @@ from pod.main.lang_settings import ALL_LANG_CHOICES, PREF_LANG_CHOICES from pod.main.utils import json_to_web_vtt from pod.main.views import in_maintenance -from pod.podfile.models import UserFolder from pod.quiz.utils import import_quiz from pod.video.models import Video, Discipline from pod.video_encode_transcript.transcript import saveVTT @@ -349,10 +348,7 @@ def enhance_subtitles(request: WSGIRequest, video_slug: str) -> HttpResponse: + str(True) ) - video_folder, created = UserFolder.objects.get_or_create( - name=video.slug, - owner=request.user, - ) + video_folder = video.get_or_create_video_folder() if enhancement_is_already_asked(video): enhancement = AIEnhancement.objects.filter(video=video).first() if enhancement.is_ready: diff --git a/pod/completion/tests/test_models.py b/pod/completion/tests/test_models.py index 97b1cdcaaf..e3d345d4f7 100644 --- a/pod/completion/tests/test_models.py +++ b/pod/completion/tests/test_models.py @@ -64,7 +64,7 @@ def test_attributs_full(self) -> None: print(" ---> test_attributs_full: OK! --- ContributorModel") - def test_attributs(self): + def test_attributs(self) -> None: contributor = Contributor.objects.get(id=2) video = Video.objects.get(id=1) self.assertEqual(contributor.video, video) @@ -76,7 +76,7 @@ def test_attributs(self): print(" [ BEGIN COMPLETION_TEST_MODELS ] ") print(" ---> test_attributs: OK! --- ContributorModel") - def test_bad_attributs(self): + def test_bad_attributs(self) -> None: video = Video.objects.get(id=1) contributor = Contributor() contributor.video = video @@ -90,7 +90,7 @@ def test_bad_attributs(self): print(" ---> test_bad_attributs: OK! --- ContributorModel") - def test_same(self): + def test_same(self) -> None: video = Video.objects.get(id=1) contributor = Contributor() contributor.video = video @@ -100,20 +100,20 @@ def test_same(self): print(" ---> test_same: OK! --- ContributorModel") - def test_delete(self): + def test_delete(self) -> None: Contributor.objects.get(id=1).delete() Contributor.objects.get(id=2).delete() self.assertTrue(Contributor.objects.all().count() == 0) print(" ---> test_delete: OK! --- ContributorModel") - def test_sites_property(self): + def test_sites_property(self) -> None: """Test the sites property of the Contributor model.""" contributor = Contributor.objects.get(id=1) self.assertEqual(contributor.sites, Video.objects.get(id=1).sites) print(" ---> test_sites_property: OK! --- ContributorModel") - def test_str(self): + def test_str(self) -> None: """Test the sites property of the Contributor model when the video is deleted.""" contributor = Contributor.objects.get(id=1) video = Video.objects.get(id=1) @@ -143,7 +143,7 @@ class DocumentModelTestCase(TestCase): "initial_data.json", ] - def setUp(self): + def setUp(self) -> None: owner = User.objects.create(username="test") videotype = Type.objects.create(title="others") video = Video.objects.create( @@ -168,7 +168,7 @@ def setUp(self): Document.objects.create(video=video, document=file) Document.objects.create(video=video) - def test_attributs_full(self): + def test_attributs_full(self) -> None: document = Document.objects.get(id=1) video = Video.objects.get(id=1) self.assertEqual(document.video, video) @@ -179,7 +179,7 @@ def test_attributs_full(self): print(" ---> test_attributs_full: OK! --- DocumentModel") - def test_attributs(self): + def test_attributs(self) -> None: document = Document.objects.get(id=2) video = Video.objects.get(id=1) self.assertEqual(document.video, video) @@ -187,20 +187,20 @@ def test_attributs(self): print(" ---> test_attributs: OK! --- DocumentModel") - def test_delete(self): + def test_delete(self) -> None: Document.objects.get(id=1).delete() Document.objects.get(id=2).delete() self.assertTrue(Document.objects.all().count() == 0) print(" ---> test_delete: OK! --- DocumentModel") - def test_sites_property(self): + def test_sites_property(self) -> None: """Test the sites property of the Contributor model.""" document = Document.objects.get(id=1) self.assertEqual(document.sites, Video.objects.get(id=1).sites) print(" ---> test_sites_property: OK! --- DocumentModel") - def test_str(self): + def test_str(self) -> None: """Test the __str__ method of the Document model.""" document = Document.objects.get(id=1) video = Video.objects.get(id=1) @@ -209,7 +209,7 @@ def test_str(self): ) print(" ---> test_str: OK! --- DocumentModel") - def test_verify_document(self): + def test_verify_document(self) -> None: """Test the verify_document method of the Document model.""" document = Document.objects.get(id=1) document.document = None @@ -223,7 +223,7 @@ class OverlayModelTestCase(TestCase): "initial_data.json", ] - def setUp(self): + def setUp(self) -> None: owner = User.objects.create(username="test") videotype = Type.objects.create(title="others") video = Video.objects.create( @@ -243,7 +243,7 @@ def setUp(self): ) Overlay.objects.create(video=video, title="overlay2", content="test") - def test_attributs_full(self): + def test_attributs_full(self) -> None: overlay = Overlay.objects.get(id=1) video = Video.objects.get(id=1) self.assertEqual(overlay.video, video) @@ -255,7 +255,7 @@ def test_attributs_full(self): print(" ---> test_attributs_full: OK! --- OverlayModel") - def test_attributs(self): + def test_attributs(self) -> None: overlay = Overlay.objects.get(id=2) video = Video.objects.get(id=1) self.assertEqual(overlay.video, video) @@ -268,7 +268,7 @@ def test_attributs(self): print(" ---> test_attributs: OK! --- OverlayModel") - def test_title(self): + def test_title(self) -> None: video = Video.objects.get(id=1) overlay = Overlay() overlay.video = video @@ -279,7 +279,7 @@ def test_title(self): print(" ---> test_title: OK! --- OverlayModel") - def test_times(self): + def test_times(self) -> None: video = Video.objects.get(id=1) overlay = Overlay() overlay.video = video @@ -295,7 +295,7 @@ def test_times(self): print(" ---> test_times: OK! --- OverlayModel") - def test_overlap(self): + def test_overlap(self) -> None: video = Video.objects.get(id=1) overlay = Overlay() overlay.video = video @@ -307,7 +307,7 @@ def test_overlap(self): print(" ---> test_overlap: OK! --- OverlayModel") - def test_delete(self): + def test_delete(self) -> None: Overlay.objects.get(id=1).delete() Overlay.objects.get(id=2).delete() self.assertTrue(Overlay.objects.all().count() == 0) @@ -320,7 +320,7 @@ class TrackModelTestCase(TestCase): "initial_data.json", ] - def setUp(self): + def setUp(self) -> None: owner = User.objects.create(username="test") videotype = Type.objects.create(title="others") video = Video.objects.create( @@ -345,7 +345,7 @@ def setUp(self): Track.objects.create(video=video, lang="fr", kind="captions", src=file) Track.objects.create(video=video, lang="en") - def test_attributs_full(self): + def test_attributs_full(self) -> None: track = Track.objects.get(id=1) video = Video.objects.get(id=1) self.assertEqual(track.video, video) @@ -356,7 +356,7 @@ def test_attributs_full(self): print(" ---> test_attributs_full: OK! --- TrackModel") - def test_attributs(self): + def test_attributs(self) -> None: track = Track.objects.get(id=2) video = Video.objects.get(id=1) self.assertEqual(track.video, video) @@ -366,7 +366,7 @@ def test_attributs(self): print(" ---> test_attributs: OK! --- TrackModel") - def test_bad_attributs(self): + def test_bad_attributs(self) -> None: track = Track.objects.get(id=1) track.kind = None self.assertRaises(ValidationError, track.clean) @@ -381,7 +381,7 @@ def test_bad_attributs(self): print(" ---> test_bad_attributs: OK! --- TrackModel") - def test_same(self): + def test_same(self) -> None: video = Video.objects.get(id=1) track = Track() track.video = video diff --git a/pod/completion/views.py b/pod/completion/views.py index 7cdd9ac61d..00cbebd907 100644 --- a/pod/completion/views.py +++ b/pod/completion/views.py @@ -22,7 +22,6 @@ from .models import Overlay from .forms import OverlayForm from .models import CustomFileModel -from pod.podfile.models import UserFolder from pod.podfile.views import get_current_session_folder, file_edit_save from pod.main.lang_settings import ALL_LANG_CHOICES, PREF_LANG_CHOICES from pod.main.settings import LANGUAGE_CODE @@ -58,9 +57,7 @@ def get_completion_home_page_title(video: Video) -> str: def video_caption_maker(request, slug): """Caption maker app.""" video = get_object_or_404(Video, slug=slug, sites=get_current_site(request)) - video_folder, created = UserFolder.objects.get_or_create( - name=video.slug, owner=request.user - ) + request.session["current_session_folder"] = video.slug action = None if ( @@ -74,6 +71,7 @@ def video_caption_maker(request, slug): request, messages.ERROR, _("You cannot complement this video.") ) raise PermissionDenied + video_folder = video.get_or_create_video_folder() if request.method == "POST" and request.POST.get("action"): action = request.POST.get("action") if action in __CAPTION_MAKER_ACTION__: @@ -111,9 +109,7 @@ def video_caption_maker(request, slug): @staff_member_required(redirect_field_name="referrer") def video_caption_maker_save(request, video): """Caption maker save view.""" - video_folder, created = UserFolder.objects.get_or_create( - name=video.slug, owner=request.user - ) + video_folder = video.get_or_create_video_folder() if request.method == "POST": error = False diff --git a/pod/cut/tests/test_views.py b/pod/cut/tests/test_views.py index cec982dc02..189d68a389 100644 --- a/pod/cut/tests/test_views.py +++ b/pod/cut/tests/test_views.py @@ -12,15 +12,20 @@ from .. import views from importlib import reload +# ggignore-start +# gitguardian:ignore +PWD = "azerty1234" # nosec +# ggignore-end + class CutVideoViewsTestCase(TestCase): fixtures = [ "initial_data.json", ] - def setUp(self): - self.user = User.objects.create(username="test", password="azerty", is_staff=True) - self.user2 = User.objects.create(username="test2", password="azerty") + def setUp(self) -> None: + self.user = User.objects.create(username="test", password=PWD, is_staff=True) + self.user2 = User.objects.create(username="test2", password=PWD) self.video = Video.objects.create( title="videotest", owner=self.user, @@ -30,7 +35,7 @@ def setUp(self): ) self.video.additional_owners.add(self.user2) - def test_maintenance(self): + def test_maintenance(self) -> None: """Test Pod maintenance mode in CutVideoViewsTestCase.""" self.client.force_login(self.user) url = reverse("cut:video_cut", kwargs={"slug": self.video.slug}) @@ -46,7 +51,7 @@ def test_maintenance(self): self.assertRedirects(response, "/maintenance/") print(" ---> test_maintenance ok") - def test_get_full_duration(self): + def test_get_full_duration(self) -> None: """Test test_get_full_duration.""" CutVideo.objects.create( video=self.video, start=time(0, 0, 0), end=time(0, 0, 10), duration="00:00:10" @@ -61,7 +66,7 @@ def test_get_full_duration(self): print(" ---> test_get_full_duration ok") @override_settings(RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY=True, USE_CUT=True) - def test_restrict_edit_video_access_staff_only(self): + def test_restrict_edit_video_access_staff_only(self) -> None: """Test test_restrict_edit_video_access_staff_only.""" reload(views) self.client.force_login(self.user2) @@ -80,7 +85,7 @@ def test_restrict_edit_video_access_staff_only(self): print(" ---> test_restrict_edit_video_access_staff_only ok") - def test_post_cut_valid_form(self): + def test_post_cut_valid_form(self) -> None: """Test test_post_cut_valid_form.""" self.client.force_login(self.user) post_data = { @@ -101,7 +106,7 @@ def test_post_cut_valid_form(self): print(" ---> test_post_cut_valid_form ok") - def test_post_cut_invalid_form(self): + def test_post_cut_invalid_form(self) -> None: """Test test_post_cut_invalid_form.""" self.client.force_login(self.user) post_data = { diff --git a/pod/enrichment/models.py b/pod/enrichment/models.py index 6511db8ba9..268cbb5395 100755 --- a/pod/enrichment/models.py +++ b/pod/enrichment/models.py @@ -22,11 +22,9 @@ import datetime if getattr(settings, "USE_PODFILE", False): + __FILEPICKER__ = True from pod.podfile.models import CustomImageModel from pod.podfile.models import CustomFileModel - from pod.podfile.models import UserFolder - - __FILEPICKER__ = True else: __FILEPICKER__ = False from pod.main.models import CustomImageModel @@ -65,18 +63,16 @@ def enrichment_to_vtt(list_enrichment, video) -> str: with open(temp_vtt_file.name, "w") as f: webvtt.write(f) if __FILEPICKER__: - videodir, created = UserFolder.objects.get_or_create( - name="%s" % video.slug, owner=video.owner - ) + video_folder = video.get_or_create_video_folder() previous_enrichment_file = CustomFileModel.objects.filter( name__startswith="enrichment", - folder=videodir, + folder=video_folder, created_by=video.owner, ) for enr in previous_enrichment_file: enr.delete() # do it like this to delete file enrichment_file, created = CustomFileModel.objects.get_or_create( - name="enrichment", folder=videodir, created_by=video.owner + name="enrichment", folder=video_folder, created_by=video.owner ) if enrichment_file.file and os.path.isfile(enrichment_file.file.path): diff --git a/pod/main/configuration.json b/pod/main/configuration.json index 19a5ad51ef..e7b1d988d9 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -49,13 +49,13 @@ "description": { "en": [ "URL for General Terms and Conditions for API uses for the AI video enhancement.", - "Example: 'https://aristote.univ.fr/cgu'", - "Project Link: https://www.demainestingenieurs.centralesupelec.fr/aristote/" + "Example: ''", + "Project Link: " ], "fr": [ "L’URL des conditions générales d’utilisation de l’API pour l’IA d’amélioration des vidéos.", - "Exemple : 'https://aristote.univ.fr/cgu'", - "Lien du projet : https://www.demainestingenieurs.centralesupelec.fr/aristote/" + "Exemple : ''", + "Lien du projet : " ] }, "pod_version_end": "", diff --git a/pod/main/static/js/main.js b/pod/main/static/js/main.js index 4922ac56f6..9828bd75f0 100644 --- a/pod/main/static/js/main.js +++ b/pod/main/static/js/main.js @@ -2,7 +2,8 @@ * @file Esup-Pod Main JavaScripts */ -/* exported getParents slideToggle fadeOut linkTo_UnCryptMailto showLoader videocheck send_form_data_vanilla */ +/* exported getParents slideToggle fadeOut linkTo_UnCryptMailto showLoader videocheck */ +/* exported send_form_data_vanilla decodeString */ // Read-only globals defined in video-script.html /* global player */ diff --git a/pod/main/templatetags/flat_page_edito_filter.py b/pod/main/templatetags/flat_page_edito_filter.py index db2e0c7108..536bd399f8 100644 --- a/pod/main/templatetags/flat_page_edito_filter.py +++ b/pod/main/templatetags/flat_page_edito_filter.py @@ -65,10 +65,10 @@ def display_content_by_block(content, request): # noqa: C901 if content.no_cache is True: params["cache"] = False - debug_elts.append("Cache is disable for this part") + debug_elts.append("Cache is disabled for this part") else: params["cache"] = True - debug_elts.append("Cache is enable for this part") + debug_elts.append("Cache is enabled for this part") if content.nb_element is not None or content.nb_element != "": params["nb-element"] = int(content.nb_element) diff --git a/pod/podfile/forms.py b/pod/podfile/forms.py index 619f185496..d14f0db972 100644 --- a/pod/podfile/forms.py +++ b/pod/podfile/forms.py @@ -49,10 +49,10 @@ class FileSizeValidator(object): ) code = "invalid_max_size" - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: self.max_size = FILE_MAX_UPLOAD_SIZE * 1024 * 1024 # MO - def __call__(self, value): + def __call__(self, value) -> None: # Check the file size filesize = len(value) if self.max_size and filesize > self.max_size: @@ -103,7 +103,7 @@ class CustomImageModelForm(forms.ModelForm): "data-maxsize": FILE_MAX_UPLOAD_SIZE * 1024 * 1024, } - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super(CustomImageModelForm, self).__init__(*args, **kwargs) self.fields["folder"].widget = forms.HiddenInput() valid_ext = FileExtensionValidator(IMAGE_ALLOWED_EXTENSIONS) diff --git a/pod/podfile/models.py b/pod/podfile/models.py index b58424c838..f850d6926c 100644 --- a/pod/podfile/models.py +++ b/pod/podfile/models.py @@ -23,6 +23,8 @@ class UserFolder(models.Model): + """Folder that will contain custom files.""" + name = models.CharField(_("Name"), max_length=255) # parent = models.ForeignKey( # 'self', blank=True, null=True, related_name='children') @@ -65,12 +67,14 @@ def __str__(self) -> str: return "{0}".format(self.name) def get_all_files(self) -> list: + """Get all files in a UserFolder.""" file_list = self.customfilemodel_set.all() image_list = self.customimagemodel_set.all() result_list = sorted(chain(image_list, file_list), key=attrgetter("uploaded_at")) return result_list def delete(self) -> None: + """Delete a UserForlder and it's content.""" for file in self.customfilemodel_set.all(): file.delete() for img in self.customimagemodel_set.all(): @@ -115,6 +119,8 @@ def get_upload_path_files(instance, filename) -> str: class BaseFileModel(models.Model): + """Esup-Pod Base File Model.""" + name = models.CharField(_("Name"), max_length=255) description = models.CharField(max_length=255, blank=True) folder = models.ForeignKey(UserFolder, on_delete=models.CASCADE) @@ -126,18 +132,23 @@ class BaseFileModel(models.Model): ) def save(self, **kwargs) -> None: + """Save a BaseFile in DB.""" path, ext = os.path.splitext(self.file.name) # if not self.name or self.name == "": self.name = os.path.basename(path) return super(BaseFileModel, self).save(**kwargs) def class_name(self) -> str: + """Get the BaseFileModel class name.""" return self.__class__.__name__ def file_exist(self) -> bool: + """Check if a BaseFileModel exist.""" return self.file and os.path.isfile(self.file.path) class Meta: + """BaseFileModel Metadata.""" + abstract = True ordering = ["name"] diff --git a/pod/podfile/rest_views.py b/pod/podfile/rest_views.py index 2090f3b86f..4cb038759f 100644 --- a/pod/podfile/rest_views.py +++ b/pod/podfile/rest_views.py @@ -1,3 +1,5 @@ +"""Esup-Pod REST views.""" + from .models import UserFolder from .models import CustomImageModel, CustomFileModel from rest_framework import serializers, viewsets diff --git a/pod/podfile/static/podfile/js/filewidget.js b/pod/podfile/static/podfile/js/filewidget.js index 213a25a31f..adee09cd85 100644 --- a/pod/podfile/static/podfile/js/filewidget.js +++ b/pod/podfile/static/podfile/js/filewidget.js @@ -3,16 +3,35 @@ * @since 2.5.0 */ -if (typeof loaded == "undefined") { - var loaded = true; +// Read-only globals defined in customfilewidget.html +/* +global id_input static_url deletefolder_url deletefile_url +*/ + +// Read-only globals defined in main.js +/* +global isJson fadeOut +*/ - const loader = ` +// Read-only globals defined in base.html +/* +global HIDE_USERNAME +*/ + + +var list_folders_sub; + +const loader = `
${gettext('Loading…')}
`; + +if (typeof loaded == "undefined") { + var loaded = true; + document.addEventListener("click", (e) => { if (!e.target.parentNode) return; if ( @@ -88,29 +107,6 @@ if (typeof loaded == "undefined") { bsdirs.hide(); }); - /*document.querySelectorAll("#open-folder-icon > *").forEach((el) => { - el.style = "pointer-events: none; cursor: pointer;"; - }); - if (document.getElementById("open-folder-icon")) { - document.getElementById("open-folder-icon").style.cursor = "pointer"; - }*/ - - /*document.addEventListener("click", (e) => { - if ( - e.target.id != "open-folder-icon" && - !e.target.matches("open-folder-icon i") - ) - return; - - //unable click on span or i - document.querySelectorAll(".folder_name").forEach((e) => { - e.style = "pointer-events: none; "; - }); - - e.preventDefault(); - document.getElementById("dirs").classList.add("open"); - });*/ - document.addEventListener("change", (e) => { if (e.target.id != "ufile") return; document.getElementById("formuploadfile").querySelector("button").click(); @@ -140,7 +136,7 @@ if (typeof loaded == "undefined") { body: data_form, processData: false, contentType: false, - headers:{ + headers: { 'X-Requested-With': 'XMLHttpRequest', //Necessary to work with is_ajax }, }) @@ -164,10 +160,10 @@ if (typeof loaded == "undefined") { showalert( gettext("Error during exchange") + - "(" + - data + - ")
" + - gettext("No data could be stored."), + "(" + + data + + ")
" + + gettext("No data could be stored."), "alert-danger" ); }); @@ -455,7 +451,7 @@ if (typeof loaded == "undefined") { document.addEventListener("click", (e) => { var contain_target = false; - if (document.getElementById("currentfolderdelete")){ + if (document.getElementById("currentfolderdelete")) { contain_target = document.getElementById("currentfolderdelete").contains(e.target); } if (e.target.id == "currentfolderdelete" || contain_target) { @@ -511,7 +507,7 @@ if (typeof loaded == "undefined") { document.addEventListener("input", (e) => { if (e.target.id != "folder-search") return; var text = e.target.value.toLowerCase(); - if (folder_searching === true ) { + if (folder_searching === true) { return; } else { if (text.length > 2 || text.length === 0) { @@ -810,7 +806,7 @@ if (typeof loaded == "undefined") { document.addEventListener("DOMContentLoaded", () => { if (typeof myFilesView !== "undefined") { getFolders(""); - folder_observer = add_folder_observer(); + var folder_observer = add_folder_observer(); folder_observer.observe(list_folders_sub, { childList: true, subtree: true }); } }); diff --git a/pod/podfile/templates/podfile/list_folder_files.html b/pod/podfile/templates/podfile/list_folder_files.html index 676bcab9ff..40892a31fa 100644 --- a/pod/podfile/templates/podfile/list_folder_files.html +++ b/pod/podfile/templates/podfile/list_folder_files.html @@ -20,7 +20,7 @@

{% endif %} {% if enr_is_already_asked %} {% endif %} {% if video.get_encoding_step == "" %} {% endif %} {% if video.encoding_in_progress %} {% endif %} {% if video.get_encoding_step == "5 : transcripting audio" %}

+ {% trans 'The video is currently being transcripted.' %}

{% endif %} diff --git a/pod/video/tests/test_models.py b/pod/video/tests/test_models.py index 6925444c04..634aa8ce38 100644 --- a/pod/video/tests/test_models.py +++ b/pod/video/tests/test_models.py @@ -3,49 +3,46 @@ * run with 'python manage.py test pod.video.tests.test_models' """ -from django.test import TestCase +from django.test import TestCase, Client +from django.db import transaction from django.db.models import Count, Q -from django.template.defaultfilters import slugify from django.db.models.fields.files import ImageFieldFile +from django.db.utils import IntegrityError +from django.template.defaultfilters import slugify from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ +from django.urls import reverse from django.conf import settings from django.core.exceptions import ValidationError -from django.db.utils import IntegrityError -from django.db import transaction -from ..models import Channel -from ..models import Theme -from ..models import Type -from ..models import Discipline -from ..models import Video -from ..models import ViewCount +from ..models import Channel, Theme, Type +from ..models import Discipline, Video, ViewCount from ..models import get_storage_path_video from ..models import VIDEOS_DIR from ..models import Notes, AdvancedNotes from ..models import UserMarkerTime, VideoAccessToken -from pod.video_encode_transcript.models import VideoRendition -from pod.video_encode_transcript.models import EncodingVideo -from pod.video_encode_transcript.models import EncodingAudio -from pod.video_encode_transcript.models import PlaylistVideo -from pod.video_encode_transcript.models import EncodingLog -from pod.video_encode_transcript.models import EncodingStep +from pod.video_encode_transcript.models import VideoRendition, PlaylistVideo +from pod.video_encode_transcript.models import EncodingVideo, EncodingAudio +from pod.video_encode_transcript.models import EncodingLog, EncodingStep -from datetime import datetime -from datetime import timedelta +from datetime import datetime, timedelta import os import uuid if getattr(settings, "USE_PODFILE", False): __FILEPICKER__ = True - from pod.podfile.models import CustomImageModel - from pod.podfile.models import UserFolder + from pod.podfile.models import CustomImageModel, UserFolder else: __FILEPICKER__ = False from pod.main.models import CustomImageModel +# ggignore-start +# gitguardian:ignore +PWD = "azerty1234" # nosec +# ggignore-end + class ChannelTestCase(TestCase): """Test the channels.""" @@ -283,7 +280,7 @@ class VideoTestCase(TestCase): def setUp(self) -> None: """Create videos to be tested.""" - user = User.objects.create(username="pod", password="pod1234pod") + user = User.objects.create(username="pod", password=PWD) # Video 1 with minimum attributes Video.objects.create( @@ -421,6 +418,78 @@ def test_get_dublin_core(self) -> None: print(" ---> test_get_dublin_core of Video: OK!") + def test_video_additional_owners_rights(self) -> None: + """Check that additional owners have the correct rights.""" + # Create 2nd and 3rd staff users + user2 = User.objects.create(username="user2", password=PWD) + user2.is_staff = True + user2.save() + user3 = User.objects.create(username="user3", password=PWD) + user3.is_staff = True + user3.save() + + # Get the test video and associated Userfolder + video = Video.objects.get(id=1) + video_folder = video.get_or_create_video_folder() + + # Add an additional owner to the video + video.additional_owners.set([user2]) + video.save() + + # Check user2 can access to video folder + client = Client() + client.force_login(user2) + response = client.post( + reverse("podfile:editfolder"), + { + "folderid": video_folder.id, + }, + follow=True, + ) + + print("response: %s" % response) + self.assertEqual(response.status_code, 200) # OK + # Replace aditional owner by another one + video.additional_owners.set([user3]) + video.save() + + # Check user2 no more access video folder + response = client.post( + reverse("podfile:editfolder"), + { + "folderid": video_folder.id, + }, + follow=True, + ) + self.assertEqual(response.status_code, 403) # forbidden + + print("---> test_video_additional_owners_rights of VideoTestCase: OK") + + def test_synced_user_folder(self) -> None: + """Check that UserFolder is synced with video params.""" + # Create 2nd staff user + user2 = User.objects.create(username="user2", password=PWD) + user2.is_staff = True + user2.save() + + # Get the test video and associated Userfolder + video = Video.objects.get(id=1) + video_folder = video.get_or_create_video_folder() + + # Then, change owner and rename the video + video.owner = user2 + video.title = "Video renamed", + video.save() + + video_folder2 = video.get_or_create_video_folder() + + # Check there is no duplicated folder + self.assertEqual(video_folder2.id, video_folder.id) + self.assertEqual(video_folder2.name, video.slug) + self.assertEqual(video_folder2.owner, video.owner) + + print("---> test_synced_user_folder of VideoTestCase: OK") + class VideoRenditionTestCase(TestCase): """Test the Video Rendition.""" @@ -429,14 +498,14 @@ class VideoRenditionTestCase(TestCase): def create_video_rendition( self, - resolution="640x360", - minrate="500k", - video_bitrate="1000k", - maxrate="2000k", - audio_bitrate="300k", - encode_mp4=False, + resolution: str = "640x360", + minrate: str = "500k", + video_bitrate: str = "1000k", + maxrate: str = "2000k", + audio_bitrate: str = "300k", + encode_mp4: bool = False, ) -> VideoRendition: - # print("create_video_rendition: %s" % resolution) + """Create a video rendition.""" return VideoRendition.objects.create( resolution=resolution, minrate=minrate, @@ -524,7 +593,7 @@ class EncodingVideoTestCase(TestCase): # fixtures = ['initial_data.json', ] def setUp(self) -> None: - user = User.objects.create(username="pod", password="pod1234pod") + user = User.objects.create(username="pod", password=PWD) Type.objects.create(title="test") Video.objects.create( title="Video1", @@ -607,7 +676,7 @@ class EncodingAudioTestCase(TestCase): # fixtures = ['initial_data.json', ] def setUp(self) -> None: - user = User.objects.create(username="pod", password="pod1234pod") + user = User.objects.create(username="pod", password=PWD) Type.objects.create(title="test") Video.objects.create( title="Video1", @@ -676,7 +745,7 @@ class PlaylistVideoTestCase(TestCase): ] def setUp(self) -> None: - user = User.objects.create(username="pod", password="pod1234pod") + user = User.objects.create(username="pod", password=PWD) Video.objects.create( title="Video1", owner=user, @@ -744,7 +813,7 @@ class EncodingLogTestCase(TestCase): ] def setUp(self) -> None: - user = User.objects.create(username="pod", password="pod1234pod") + user = User.objects.create(username="pod", password=PWD) Video.objects.create( title="Video1", owner=user, @@ -785,7 +854,7 @@ class EncodingStepTestCase(TestCase): ] def setUp(self) -> None: - user = User.objects.create(username="pod", password="pod1234pod") + user = User.objects.create(username="pod", password=PWD) Video.objects.create( title="Video1", owner=user, @@ -832,7 +901,7 @@ class NotesTestCase(TestCase): ] def setUp(self) -> None: - user = User.objects.create(username="pod", password="pod1234pod") + user = User.objects.create(username="pod", password=PWD) Video.objects.create( title="Video1", owner=user, @@ -910,7 +979,7 @@ class UserMarkerTimeTestCase(TestCase): ] def setUp(self) -> None: - user = User.objects.create(username="pod", password="pod1234pod") + user = User.objects.create(username="pod", password=PWD) Video.objects.create( title="Video1", owner=user, @@ -985,7 +1054,7 @@ class VideoAccessTokenTestCase(TestCase): ] def setUp(self) -> None: - user = User.objects.create(username="pod", password="pod1234pod") + user = User.objects.create(username="pod", password=PWD) print("VIDEO: %s" % Video.objects.all().count()) self.video = Video.objects.create( title="Video1", diff --git a/pod/video_encode_transcript/Encoding_video_model.py b/pod/video_encode_transcript/Encoding_video_model.py index 7781c4995a..deaa5712a8 100644 --- a/pod/video_encode_transcript/Encoding_video_model.py +++ b/pod/video_encode_transcript/Encoding_video_model.py @@ -48,7 +48,6 @@ if getattr(settings, "USE_PODFILE", False): __FILEPICKER__ = True from pod.podfile.models import CustomImageModel - from pod.podfile.models import UserFolder from pod.podfile.models import CustomFileModel else: __FILEPICKER__ = False @@ -59,7 +58,7 @@ class Encoding_video_model(Encoding_video): """Encoding video model.""" - def remove_old_data(self): + def remove_old_data(self) -> None: """Remove data from previous encoding.""" video_to_encode = Video.objects.get(id=self.id) video_to_encode.thumbnail = None @@ -91,7 +90,7 @@ def remove_previous_encoding_log(self, video_to_encode): msg += "Audio: Nothing to delete" return msg - def remove_previous_encoding_objects(self, model_class, video_to_encode): + def remove_previous_encoding_objects(self, model_class, video_to_encode) -> str: """Remove previously encoded objects of the given model.""" msg = "\n" object_type = model_class.__name__ @@ -106,15 +105,15 @@ def remove_previous_encoding_objects(self, model_class, video_to_encode): msg += "Video: Nothing to delete" return msg - def remove_previous_encoding_video(self, video_to_encode): + def remove_previous_encoding_video(self, video_to_encode) -> str: """Remove previously encoded video.""" return self.remove_previous_encoding_objects(EncodingVideo, video_to_encode) - def remove_previous_encoding_audio(self, video_to_encode): + def remove_previous_encoding_audio(self, video_to_encode) -> str: """Remove previously encoded audio.""" return self.remove_previous_encoding_objects(EncodingAudio, video_to_encode) - def remove_previous_encoding_playlist(self, video_to_encode): + def remove_previous_encoding_playlist(self, video_to_encode) -> str: """Remove previously encoded playlist.""" return self.remove_previous_encoding_objects(PlaylistVideo, video_to_encode) @@ -122,7 +121,7 @@ def get_true_path(self, original): """Get the true path by replacing the MEDIA_ROOT from the original path.""" return original.replace(os.path.join(settings.MEDIA_ROOT, ""), "") - def store_json_list_mp3_m4a_files(self, info_video, video_to_encode): + def store_json_list_mp3_m4a_files(self, info_video, video_to_encode) -> None: """Store JSON list of MP3 and M4A files for encoding.""" encoding_list = ["list_m4a_files", "list_mp3_files"] for encode_item in encoding_list: @@ -140,7 +139,7 @@ def store_json_list_mp3_m4a_files(self, info_video, video_to_encode): source_file=self.get_true_path(mp3_files[audio_file]), ) - def store_json_list_mp4_hls_files(self, info_video, video_to_encode): + def store_json_list_mp4_hls_files(self, info_video, video_to_encode) -> None: mp4_files = info_video["list_mp4_files"] for video_file in mp4_files: if not check_file(mp4_files[video_file]): @@ -188,7 +187,7 @@ def store_json_list_mp4_hls_files(self, info_video, video_to_encode): source_file=playlist_file, ) - def store_json_encoding_log(self, info_video, video_to_encode): + def store_json_encoding_log(self, info_video, video_to_encode) -> None: # Need to modify start and stop log_to_text = "" # logs = info_video["encoding_log"] @@ -219,13 +218,10 @@ def store_json_encoding_log(self, info_video, video_to_encode): ) encoding_log.save() - def store_json_list_subtitle_files(self, info_video, video_to_encode): + def store_json_list_subtitle_files(self, info_video, video_to_encode) -> None: list_subtitle_files = info_video["list_subtitle_files"] if __FILEPICKER__: - videodir, created = UserFolder.objects.get_or_create( - name="%s" % video_to_encode.slug, - owner=video_to_encode.owner, - ) + videodir = video_to_encode.get_or_create_video_folder() for sub in list_subtitle_files: if not check_file(list_subtitle_files[sub][1]): @@ -259,16 +255,13 @@ def store_json_list_subtitle_files(self, info_video, video_to_encode): enrich_ready=True, ) - def store_json_list_thumbnail_files(self, info_video): + def store_json_list_thumbnail_files(self, info_video) -> Video: """store_json_list_thumbnail_files.""" video = Video.objects.get(id=self.id) list_thumbnail_files = info_video["list_thumbnail_files"] thumbnail = CustomImageModel() if __FILEPICKER__: - videodir, created = UserFolder.objects.get_or_create( - name="%s" % video.slug, - owner=video.owner, - ) + videodir = video.get_or_create_video_folder() thumbnail = CustomImageModel(folder=videodir, created_by=video.owner) for index, thumbnail_path in enumerate(list_thumbnail_files): if check_file(list_thumbnail_files[thumbnail_path]): @@ -298,7 +291,7 @@ def store_json_list_overview_files(self, info_video) -> Video: video.save() return video - def wait_for_file(self, filepath): + def wait_for_file(self, filepath) -> None: time_to_wait = 40 time_counter = 0 while not os.path.exists(filepath): @@ -367,7 +360,7 @@ def get_create_thumbnail_command_from_video(self, video_to_encode): encoding_log.save() return thumbnail_command - def recreate_thumbnail(self): + def recreate_thumbnail(self) -> None: self.create_output_dir() self.get_video_data() info_video = {} diff --git a/pod/video_encode_transcript/transcript.py b/pod/video_encode_transcript/transcript.py index 1cda78785e..f55bbb1ed0 100644 --- a/pod/video_encode_transcript/transcript.py +++ b/pod/video_encode_transcript/transcript.py @@ -38,7 +38,6 @@ if getattr(settings, "USE_PODFILE", False): __FILEPICKER__ = True from pod.podfile.models import CustomFileModel - from pod.podfile.models import UserFolder else: __FILEPICKER__ = False from pod.main.models import CustomFileModel @@ -160,9 +159,7 @@ def saveVTT(video: Video, webvtt: WebVTT, lang_code: str = None): improveCaptionsAccessibility(webvtt) msg += "\nstore vtt file in bdd with CustomFileModel model file field" if __FILEPICKER__: - videodir, created = UserFolder.objects.get_or_create( - name="%s" % video.slug, owner=video.owner - ) + videodir = video.get_or_create_video_folder() """ previousSubtitleFile = CustomFileModel.objects.filter( name__startswith="subtitle_%s" % lang, From e015c57e91f6646b9663dae57e8de675195c293f Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 28 Jun 2024 13:29:25 +0000 Subject: [PATCH 03/24] Fixup. Format code with Black --- .../management/commands/clean_video_files.py | 37 +++++++++---------- pod/video/models.py | 4 +- pod/video/tests/test_models.py | 2 +- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/pod/video/management/commands/clean_video_files.py b/pod/video/management/commands/clean_video_files.py index 66f9c8ecee..05c1184033 100644 --- a/pod/video/management/commands/clean_video_files.py +++ b/pod/video/management/commands/clean_video_files.py @@ -26,21 +26,18 @@ def clean_duplicated_UserFolders(dry_run: bool) -> int: if found > 1: # Only one folder is needed by video - print( - "# More than one Userfolder found for vid %s (%s)." % ( - slugid, found) - ) + print("# More than one Userfolder found for vid %s (%s)." % (slugid, found)) # We arbitrary keep the first folder. primary_folder = videodirs.first() print( - "Primary folder %s `%s` had %s files" % ( - primary_folder.id, primary_folder, - len(primary_folder.get_all_files()) - ) + "Primary folder %s `%s` had %s files" + % (primary_folder.id, primary_folder, len(primary_folder.get_all_files())) ) for folder in list(videodirs.all())[1:]: - print(" + Add %s files from folder %s `%s`" % ( - len(folder.get_all_files()), folder.id, folder)) + print( + " + Add %s files from folder %s `%s`" + % (len(folder.get_all_files()), folder.id, folder) + ) # Move every file to the primary folder if not dry_run: for file in folder.get_all_files(): @@ -52,10 +49,8 @@ def clean_duplicated_UserFolders(dry_run: bool) -> int: if not dry_run: folder.delete() print( - "Primary folder %s `%s` has now %s files\n" % ( - primary_folder.id, primary_folder, - len(primary_folder.get_all_files()) - ) + "Primary folder %s `%s` has now %s files\n" + % (primary_folder.id, primary_folder, len(primary_folder.get_all_files())) ) return nb_duplicated @@ -69,10 +64,7 @@ def update_folder_rights(vid: Video, folder: UserFolder) -> int: add_own = vid.additional_owners.all() expected = add_own.count() - print( - "* Regular folder: %s; %s; %s/%s" % ( - folder.id, folder, before, expected) - ) + print("* Regular folder: %s; %s; %s/%s" % (folder.id, folder, before, expected)) # Update it’s additional owners vid.update_additional_owners_rights() @@ -111,7 +103,9 @@ def handle(self, *args, **options) -> None: self.nb_deleted = {} if clean_type in ["userfolder", "all"]: if USE_PODFILE: - self.nb_deleted["userfolder"] = clean_duplicated_UserFolders(options["dry"]) + self.nb_deleted["userfolder"] = clean_duplicated_UserFolders( + options["dry"] + ) if self.nb_deleted["userfolder"] == 0: print(" No duplicated UserFolders found.") self.nb_deleted["userfolder"] += self.clean_userFolders(options["dry"]) @@ -140,7 +134,10 @@ def clean_userFolders(self, dry_run: bool) -> int: except Video.DoesNotExist: folder_content = folder.get_all_files() - print("* Orphaned folder: %s; %s; %s" % (folder, folder_content, len(folder_content))) + print( + "* Orphaned folder: %s; %s; %s" + % (folder, folder_content, len(folder_content)) + ) folder_deleted += 1 files_deleted += len(folder_content) if not dry_run: diff --git a/pod/video/models.py b/pod/video/models.py index 28afdb14ed..0c9930da89 100644 --- a/pod/video/models.py +++ b/pod/video/models.py @@ -1428,9 +1428,7 @@ def update_additional_owners_rights(self) -> None: if USE_PODFILE: try: # First, search for exact Userfolder match - videodir = UserFolder.objects.get( - name="%s" % self.slug, owner=self.owner - ) + videodir = UserFolder.objects.get(name="%s" % self.slug, owner=self.owner) # Ensure all additional users will get access to this folder videodir.users.set(self.additional_owners.all()) videodir.save() diff --git a/pod/video/tests/test_models.py b/pod/video/tests/test_models.py index 634aa8ce38..5dc39a9ddd 100644 --- a/pod/video/tests/test_models.py +++ b/pod/video/tests/test_models.py @@ -478,7 +478,7 @@ def test_synced_user_folder(self) -> None: # Then, change owner and rename the video video.owner = user2 - video.title = "Video renamed", + video.title = ("Video renamed",) video.save() video_folder2 = video.get_or_create_video_folder() From ed4e24bf631c70f8e6be519c4fc57c4928dbe711 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 28 Jun 2024 13:29:49 +0000 Subject: [PATCH 04/24] Auto-update configuration files --- CONFIGURATION_FR.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONFIGURATION_FR.md b/CONFIGURATION_FR.md index 0594364eeb..2e3988eaf1 100644 --- a/CONFIGURATION_FR.md +++ b/CONFIGURATION_FR.md @@ -727,8 +727,8 @@ Mettre `USE_AI_ENHANCEMENT` à True pour activer cette application.
* `AI_ENHANCEMENT_CGU_URL` > valeur par défaut : `` >> L’URL des conditions générales d’utilisation de l’API pour l’IA d’amélioration des vidéos.
- >> Exemple : 'https://aristote.univ.fr/cgu'
- >> Lien du projet : https://www.demainestingenieurs.centralesupelec.fr/aristote/
+ >> Exemple : ''
+ >> Lien du projet :
* `AI_ENHANCEMENT_CLIENT_ID` > valeur par défaut : `mocked_id` >> L’ID du client de l’IA d’amélioration des vidéos.
From f47939c33288cd23af8f60da9faefb4e7ed7a00d Mon Sep 17 00:00:00 2001 From: Ptitloup Date: Mon, 1 Jul 2024 09:11:57 +0200 Subject: [PATCH 05/24] [DONE] Ptitloup/remote encoding fix (#1139) * remove log if all is ok * remove split audio when running inference with whisper * remove unused local variable and replace encode by transcript in transcript task * fix audio in transcript model for whisper model * add y args on ffmpeg cmd to force overwrite existing file * add task id in celery task to check if several task launch in same time --- .../encoding_settings.py | 4 ++-- pod/video_encode_transcript/encoding_tasks.py | 11 ++++++----- .../transcript_model.py | 18 ++++++++++++++++-- .../transcripting_tasks.py | 13 +++++++------ 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/pod/video_encode_transcript/encoding_settings.py b/pod/video_encode_transcript/encoding_settings.py index d61ab663e5..2b32558e55 100644 --- a/pod/video_encode_transcript/encoding_settings.py +++ b/pod/video_encode_transcript/encoding_settings.py @@ -60,7 +60,7 @@ # FFMPEG_CREATE_THUMBNAIL = # '-map 0:%(index)s -vframes 1 -an -ss %(time)s -y "%(output)s" ' FFMPEG_CREATE_THUMBNAIL = ( - '-vf "fps=1/(%(duration)s/%(nb_thumbnail)s)" -vsync vfr "%(output)s_%%04d.png"' + '-vf "fps=1/(%(duration)s/%(nb_thumbnail)s)" -vsync vfr -y "%(output)s_%%04d.png"' ) FFMPEG_EXTRACT_SUBTITLE = '-map 0:%(index)s -f webvtt -y "%(output)s" ' @@ -69,7 +69,7 @@ "'fps=fps=(%(image_count)s/%(duration)s), " "scale=%(width)sx%(height)s, " "tile=%(image_count)sx1' " - "-frames:v 1 '%(output)s' " + "-frames:v 1 -y '%(output)s' " ) FFMPEG_DRESSING_OUTPUT = ' -c:v libx264 -y -vsync 0 "%(output)s" ' diff --git a/pod/video_encode_transcript/encoding_tasks.py b/pod/video_encode_transcript/encoding_tasks.py index ea532b0a5a..a9430d51ae 100644 --- a/pod/video_encode_transcript/encoding_tasks.py +++ b/pod/video_encode_transcript/encoding_tasks.py @@ -47,9 +47,9 @@ # celery -A pod.video_encode_transcript.encoding_tasks worker -l INFO -Q encoding -@encoding_app.task +@encoding_app.task(bind=True) def start_encoding_task( - video_id, video_path, cut_start, cut_end, json_dressing, dressing_input + self, video_id, video_path, cut_start, cut_end, json_dressing, dressing_input ): """Start the encoding of the video.""" print("Start the encoding of the video") @@ -73,22 +73,23 @@ def start_encoding_task( "json_dressing": json_dressing, "dressing_input": dressing_input, } + msg = "Task id : %s\n" % self.request.id try: response = requests.post(url, json=data, headers=Headers) if response.status_code != 200: - msg = "Calling store remote encoding error: {} {}".format( + msg += "Calling store remote encoding error: {} {}".format( response.status_code, response.reason ) logger.error(msg + "\n" + str(response.content)) else: - logger.info("Call importing encoded task ok") + logger.info(msg + "Call importing encoding task ok") except ( requests.exceptions.HTTPError, requests.exceptions.ConnectionError, requests.exceptions.InvalidURL, requests.exceptions.Timeout, ) as exception: - msg = "Exception: {}".format(type(exception).__name__) + msg += "Exception: {}".format(type(exception).__name__) msg += "\nException message: {}".format(exception) logger.error(msg) diff --git a/pod/video_encode_transcript/transcript_model.py b/pod/video_encode_transcript/transcript_model.py index cb48a84c21..24bd535909 100644 --- a/pod/video_encode_transcript/transcript_model.py +++ b/pod/video_encode_transcript/transcript_model.py @@ -418,7 +418,7 @@ def main_whisper_transcript(norm_mp3_file, duration, lang): "download_root" ], ) - + ''' for start_trim in range(0, duration, TRANSCRIPTION_AUDIO_SPLIT_TIME): log.info("start_trim: " + str(start_trim)) audio = convert_samplerate( @@ -436,7 +436,21 @@ def main_whisper_transcript(norm_mp3_file, duration, lang): segment["text"], ) webvtt.captions.append(caption) - + ''' + audio = convert_samplerate( + norm_mp3_file, + desired_sample_rate, + 0, + duration + ) + transcription = model.transcribe(audio, language=lang) + for segment in transcription["segments"]: + caption = Caption( + sec_to_timestamp(segment["start"]), + sec_to_timestamp(segment["end"]), + segment["text"], + ) + webvtt.captions.append(caption) inference_end = timer() - inference_start msg += "\nInference took %0.3fs." % inference_end return msg, webvtt, all_text diff --git a/pod/video_encode_transcript/transcripting_tasks.py b/pod/video_encode_transcript/transcripting_tasks.py index fefa0435e5..b059e50167 100644 --- a/pod/video_encode_transcript/transcripting_tasks.py +++ b/pod/video_encode_transcript/transcripting_tasks.py @@ -33,7 +33,7 @@ mailhost=EMAIL_HOST, fromaddr=DEFAULT_FROM_EMAIL, toaddrs=admins_email, - subject="[POD ENCODING] Encoding Log Mail", + subject="[POD TRANSCRIPT] Transcripting Log Mail", ) if not TEST_REMOTE_ENCODE: logger.addHandler(smtp_handler) @@ -57,8 +57,8 @@ # celery \ # -A pod.video_encode_transcript.transcripting_tasks worker \ # -l INFO -Q transcripting -@transcripting_app.task -def start_transcripting_task(video_id, mp3filepath, duration, lang): +@transcripting_app.task(bind=True) +def start_transcripting_task(self, video_id, mp3filepath, duration, lang): """Start the transcripting of the video.""" from .transcript_model import start_transcripting from ..main.settings import MEDIA_ROOT @@ -76,21 +76,22 @@ def start_transcripting_task(video_id, mp3filepath, duration, lang): Headers = {"Authorization": "Token %s" % POD_API_TOKEN} url = POD_API_URL.strip("/") + "/store_remote_transcripted_video/?id=%s" % video_id data = {"video_id": video_id, "msg": msg, "temp_vtt_file": temp_vtt_file.name} + msg = "Task id : %s\n" % self.request.id try: response = requests.post(url, json=data, headers=Headers) if response.status_code != 200: - msg = "Calling store remote transcoding error: {} {}".format( + msg += "Calling store remote transcoding error: {} {}".format( response.status_code, response.reason ) logger.error(msg + "\n" + str(response.content)) else: - logger.info("Call importing transcript task ok") + logger.info(msg + "Call importing transcript task ok") except ( requests.exceptions.HTTPError, requests.exceptions.ConnectionError, requests.exceptions.InvalidURL, requests.exceptions.Timeout, ) as exception: - msg = "Exception: {}".format(type(exception).__name__) + msg += "Exception: {}".format(type(exception).__name__) msg += "\nException message: {}".format(exception) logger.error(msg) From 6350758c171dd6fb3af8d7d94f743c4a8acba7f6 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 1 Jul 2024 07:12:36 +0000 Subject: [PATCH 06/24] Fixup. Format code with Black --- pod/video_encode_transcript/transcript_model.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pod/video_encode_transcript/transcript_model.py b/pod/video_encode_transcript/transcript_model.py index 24bd535909..51b1bf8797 100644 --- a/pod/video_encode_transcript/transcript_model.py +++ b/pod/video_encode_transcript/transcript_model.py @@ -418,7 +418,7 @@ def main_whisper_transcript(norm_mp3_file, duration, lang): "download_root" ], ) - ''' + """ for start_trim in range(0, duration, TRANSCRIPTION_AUDIO_SPLIT_TIME): log.info("start_trim: " + str(start_trim)) audio = convert_samplerate( @@ -436,13 +436,8 @@ def main_whisper_transcript(norm_mp3_file, duration, lang): segment["text"], ) webvtt.captions.append(caption) - ''' - audio = convert_samplerate( - norm_mp3_file, - desired_sample_rate, - 0, - duration - ) + """ + audio = convert_samplerate(norm_mp3_file, desired_sample_rate, 0, duration) transcription = model.transcribe(audio, language=lang) for segment in transcription["segments"]: caption = Caption( From c88bac4c8fbfe6dee99acf45d4a128d7aa53ab32 Mon Sep 17 00:00:00 2001 From: Ptitloup Date: Tue, 2 Jul 2024 11:07:25 +0200 Subject: [PATCH 07/24] remove site in theme rest view (#1172) --- pod/video/rest_views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pod/video/rest_views.py b/pod/video/rest_views.py index 9812a929c6..607af34643 100644 --- a/pod/video/rest_views.py +++ b/pod/video/rest_views.py @@ -54,7 +54,6 @@ class Meta: "headband", "description", "channel", - "site", ) From ec43f6bb87a22d1fbf1f4d095f451398eae75f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Coze?= <96086580+SebastienCozeDev@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:50:03 +0200 Subject: [PATCH 08/24] [DONE] Feature: Add menu in the video card and the video page (#1171) * :sparkles: Add modal for options * :bug: Fix the menu * :sparkles: Update the options menu * :sparkles: Add dropdown divider above the deletion options * :fire: Remove unnecessary modal * :art: Re-indent correctly the code & add line at the file end * :lipstick: Improve the link titles * :art: Reorganize the dropdown menu in the dashboard * :bug: Fix the favorite star in video list page * :lipstick: Standardize styles * :art: Add li tag in the link video dropdown menu * :sparkles: Add dropdown menu for the meeting cards * :art: Add line at the file end * :globe_with_meridians: Update translation --- pod/locale/fr/LC_MESSAGES/django.mo | Bin 219439 -> 219823 bytes pod/locale/fr/LC_MESSAGES/django.po | 9 +- pod/main/static/css/pod.css | 8 +- .../templates/meeting/link_meeting.html | 36 ++---- .../meeting/link_meeting_dropdown_menu.html | 72 +++++++++++ .../templates/meeting/meeting_card.html | 2 +- .../playlist/playlist-list-modal.html | 89 ++++++++------ pod/video/static/js/link-video-modal.js | 28 +++++ pod/video/templates/videos/link_video.html | 50 ++------ .../videos/link_video_dropdown_menu.html | 115 ++++++++++++++++++ .../templates/videos/video_row_select.html | 68 +---------- 11 files changed, 301 insertions(+), 176 deletions(-) create mode 100644 pod/meeting/templates/meeting/link_meeting_dropdown_menu.html create mode 100644 pod/video/static/js/link-video-modal.js create mode 100644 pod/video/templates/videos/link_video_dropdown_menu.html diff --git a/pod/locale/fr/LC_MESSAGES/django.mo b/pod/locale/fr/LC_MESSAGES/django.mo index 888f37b49889b5323fe8c9d6e2506803500fbdaf..51312828edb5efd6e6531c95c9a1f75651633b4d 100644 GIT binary patch delta 48147 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

#mcA~ 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 delta 47816 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%0A9koQh#szKYq7V=$Zc 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ŋoyMj}4ygv^Fo#8prYX^)jL0@cw2 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\n" +"Last-Translator: SebastienCozeDev \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" "Language: fr\n" "MIME-Version: 1.0\n" @@ -10952,5 +10952,10 @@ msgstr "Résultats de la recherche" msgid "Esup-Pod xAPI" msgstr "xAPI Esup-Pod" +#: pod/video/templates/videos/link_video.html +#: pod/meeting/templates/meeting/link_meeting.html +msgid "More options" +msgstr "Plus d'options" + #~ msgid "Quiz(zes)" #~ msgstr "Quiz" diff --git a/pod/main/static/css/pod.css b/pod/main/static/css/pod.css index 3866a593e3..16e09594ce 100755 --- a/pod/main/static/css/pod.css +++ b/pod/main/static/css/pod.css @@ -585,8 +585,9 @@ div.card a img { .card-footer-pod { position: absolute; bottom: 4rem; - right: 0; + right: 1rem; border: none; + border-radius: 0.375rem; display: flex; flex-wrap: wrap-reverse; justify-content: flex-end; @@ -1870,3 +1871,8 @@ body[data-admin-utc-offset] .question-container li.bi::before { margin-right: 0.5em; } + +/** LINK VIDEO MODAL **/ +#link-video-modal .modal-dialog { + font-size: 1.7em; +} diff --git a/pod/meeting/templates/meeting/link_meeting.html b/pod/meeting/templates/meeting/link_meeting.html index 9a1362dd37..3b46321ed4 100644 --- a/pod/meeting/templates/meeting/link_meeting.html +++ b/pod/meeting/templates/meeting/link_meeting.html @@ -7,35 +7,19 @@ {% endif %} - - - - - - - - - - - - - {% if not meeting_disable_record %} - - - - {% endif %} {% endif %} -{% if meeting.owner == request.user or request.user.is_superuser or perms.meeting.delete_meeting %} - {% if not meeting.is_personal %} - - + {% if meeting.owner == request.user or request.user.is_superuser or request.user in meeting.additional_owners.all or perms.meeting.change_meeting %} + + {% include 'meeting/link_meeting_dropdown_menu.html' %} {% endif %} -{% endif %} {%endspaceless%} diff --git a/pod/meeting/templates/meeting/link_meeting_dropdown_menu.html b/pod/meeting/templates/meeting/link_meeting_dropdown_menu.html new file mode 100644 index 0000000000..77f95709d5 --- /dev/null +++ b/pod/meeting/templates/meeting/link_meeting_dropdown_menu.html @@ -0,0 +1,72 @@ +{% load i18n %} + +

diff --git a/pod/meeting/templates/meeting/meeting_card.html b/pod/meeting/templates/meeting/meeting_card.html index 1c3732639d..c8f63a7f29 100644 --- a/pod/meeting/templates/meeting/meeting_card.html +++ b/pod/meeting/templates/meeting/meeting_card.html @@ -55,7 +55,7 @@
{% if meeting.owner == request.user or request.user.is_superuser or perms.meeting.change_meeting or request.user in meeting.additional_owners.all %} -
+
{% include "meeting/link_meeting.html" %}
{% endif %} diff --git a/pod/playlist/templates/playlist/playlist-list-modal.html b/pod/playlist/templates/playlist/playlist-list-modal.html index 8b927905d9..86acd1a5dd 100644 --- a/pod/playlist/templates/playlist/playlist-list-modal.html +++ b/pod/playlist/templates/playlist/playlist-list-modal.html @@ -4,49 +4,60 @@ {% load favorites_playlist %} {% block page_extra_head %} - + {% endblock page_extra_head %} {% endspaceless %} From e9515aa601d9085dc98079096b22ddb09b258865 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 5 Jul 2024 13:50:30 +0000 Subject: [PATCH 09/24] Fixup. Format code with Prettier --- pod/video/static/js/link-video-modal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pod/video/static/js/link-video-modal.js b/pod/video/static/js/link-video-modal.js index 0b5f25586d..0c2150c8b6 100644 --- a/pod/video/static/js/link-video-modal.js +++ b/pod/video/static/js/link-video-modal.js @@ -23,6 +23,6 @@ function addEventListenerForModal() { } } -document.addEventListener('DOMContentLoaded', function () { +document.addEventListener("DOMContentLoaded", function () { addEventListenerForModal(); }); From c3318821be2dda0b8f07da33c9d3bf8e46012580 Mon Sep 17 00:00:00 2001 From: fanfounet Date: Wed, 10 Jul 2024 12:06:54 +0200 Subject: [PATCH 10/24] [DONE] Fanfounet / webtv speaker app (#1145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add speaker app and admin files * add in progress speaker management * recopie après pb guardian * recopie sauvegarde * add init migrations file * add use speaker in test settings * change lang * update fix --------- Co-authored-by: Charneau Franck --- CONFIGURATION_EN.md | 13 +- CONFIGURATION_FR.md | 9 + pod/completion/models.py | 2 +- pod/completion/static/js/completion.js | 5 + .../contributor/list_contributor.html | 2 +- .../templates/video_completion.html | 40 +++ pod/completion/urls.py | 6 + pod/completion/utils.py | 9 +- pod/completion/views.py | 178 ++++++++++ pod/locale/fr/LC_MESSAGES/django.mo | Bin 219823 -> 224382 bytes pod/locale/fr/LC_MESSAGES/django.po | 297 ++++++++++++----- pod/locale/fr/LC_MESSAGES/djangojs.mo | Bin 21857 -> 22210 bytes pod/locale/fr/LC_MESSAGES/djangojs.po | 22 +- pod/locale/nl/LC_MESSAGES/django.mo | Bin 2059 -> 2059 bytes pod/locale/nl/LC_MESSAGES/django.po | 307 +++++++++++++----- pod/locale/nl/LC_MESSAGES/djangojs.po | 22 +- pod/main/configuration.json | 31 ++ pod/main/templates/navbar.html | 3 + pod/main/test_settings.py | 1 + pod/settings.py | 2 + pod/speaker/__init__.py | 0 pod/speaker/admin.py | 38 +++ pod/speaker/apps.py | 12 + pod/speaker/context_processors.py | 12 + pod/speaker/forms.py | 61 ++++ pod/speaker/migrations/__init__.py | 0 pod/speaker/models.py | 70 ++++ .../static/speaker/js/speakers-management.js | 97 ++++++ .../templates/speaker/form_speaker.html | 35 ++ .../templates/speaker/list_speaker.html | 31 ++ .../templates/speaker/speaker_modal.html | 32 ++ .../speaker/speakers_management.html | 77 +++++ pod/speaker/templatetags/__init__.py | 0 .../templatetags/speaker_template_tags.py | 24 ++ pod/speaker/tests/__init__.py | 0 pod/speaker/tests/test_models.py | 73 +++++ pod/speaker/tests/test_views.py | 107 ++++++ pod/speaker/urls.py | 13 + pod/speaker/utils.py | 28 ++ pod/speaker/views.py | 274 ++++++++++++++++ pod/urls.py | 7 + pod/video/templates/videos/video-info.html | 16 + 42 files changed, 1779 insertions(+), 177 deletions(-) create mode 100644 pod/speaker/__init__.py create mode 100644 pod/speaker/admin.py create mode 100644 pod/speaker/apps.py create mode 100644 pod/speaker/context_processors.py create mode 100644 pod/speaker/forms.py create mode 100644 pod/speaker/migrations/__init__.py create mode 100644 pod/speaker/models.py create mode 100644 pod/speaker/static/speaker/js/speakers-management.js create mode 100644 pod/speaker/templates/speaker/form_speaker.html create mode 100644 pod/speaker/templates/speaker/list_speaker.html create mode 100644 pod/speaker/templates/speaker/speaker_modal.html create mode 100644 pod/speaker/templates/speaker/speakers_management.html create mode 100644 pod/speaker/templatetags/__init__.py create mode 100644 pod/speaker/templatetags/speaker_template_tags.py create mode 100644 pod/speaker/tests/__init__.py create mode 100644 pod/speaker/tests/test_models.py create mode 100644 pod/speaker/tests/test_views.py create mode 100644 pod/speaker/urls.py create mode 100644 pod/speaker/utils.py create mode 100644 pod/speaker/views.py diff --git a/CONFIGURATION_EN.md b/CONFIGURATION_EN.md index c6482866aa..d35e4650b3 100644 --- a/CONFIGURATION_EN.md +++ b/CONFIGURATION_EN.md @@ -399,8 +399,8 @@ Set `USE_AI_ENHANCEMENT` to True to activate this application.
* `AI_ENHANCEMENT_CGU_URL` > default value: `` >> URL for General Terms and Conditions for API uses for the AI video enhancement.
- >> Example: 'https://aristote.univ.fr/cgu'
- >> Project Link: https://www.demainestingenieurs.centralesupelec.fr/aristote/
+ >> Example: ''
+ >> Project Link:
* `AI_ENHANCEMENT_CLIENT_ID` > default value: `mocked_id` >> The video enhancement AI client ID.
@@ -622,6 +622,15 @@ Set `USE_DRESSING` to True to activate this application.
### +### Seaker application configuration + +Speaker application to add speakers to video.
+Set `USE_SPEAKER` to True to activate this application.
+ +* `USE_SPEAKER` + > default value: `False` + >> Activation of the Speaker application
+ ### Video import application configuration Import_video app to import external videos into Pod.
diff --git a/CONFIGURATION_FR.md b/CONFIGURATION_FR.md index 2e3988eaf1..27d161f1ec 100644 --- a/CONFIGURATION_FR.md +++ b/CONFIGURATION_FR.md @@ -1028,6 +1028,15 @@ Mettre `USE_DRESSING` à True pour activer cette application.
### Configuration de l’application enrichment +### Configuration de l’application Intervenant + +Application Intervenant permettant d'ajouter des intervenants à la vidéo.
+Mettre `USE_SPEAKER` à True pour activer cette application.
+ +* `USE_SPEAKER` + > valeur par défaut : `False` + >> Activation de l’application Intervenant
+ ### Configuration de l’application d’import vidéo Application Import_video permettant d’importer des vidéos externes dans Pod.
diff --git a/pod/completion/models.py b/pod/completion/models.py index a3c73c76ba..f0cacb3d70 100644 --- a/pod/completion/models.py +++ b/pod/completion/models.py @@ -79,7 +79,7 @@ class Contributor(models.Model): video = models.ForeignKey(Video, verbose_name=_("video"), on_delete=models.CASCADE) name = models.CharField( - verbose_name=_("lastname / firstname"), max_length=200, default="" + verbose_name=_("last name / first name"), max_length=200, default="" ) email_address = models.EmailField( verbose_name=_("mail"), null=True, blank=True, default="" diff --git a/pod/completion/static/js/completion.js b/pod/completion/static/js/completion.js index a0ba86f4a8..5c492c94a8 100644 --- a/pod/completion/static/js/completion.js +++ b/pod/completion/static/js/completion.js @@ -49,6 +49,7 @@ var ajaxfail = function (data, form) { document.addEventListener("submit", (e) => { if ( e.target.id !== "form_new_contributor" && + e.target.id !== "form_new_speaker" && e.target.id !== "form_new_document" && e.target.id !== "form_new_track" && e.target.id !== "form_new_overlay" && @@ -219,6 +220,10 @@ var sendAndGetForm = async function (elt, action, name, form, list) { deleteConfirm = confirm( gettext("Are you sure you want to delete this contributor?"), ); + } else if (name === "speaker") { + deleteConfirm = confirm( + gettext("Are you sure you want to delete this speaker?"), + ); } else if (name === "document") { deleteConfirm = confirm( gettext("Are you sure you want to delete this document?"), diff --git a/pod/completion/templates/contributor/list_contributor.html b/pod/completion/templates/contributor/list_contributor.html index 285eb19eb8..cb8363cf73 100644 --- a/pod/completion/templates/contributor/list_contributor.html +++ b/pod/completion/templates/contributor/list_contributor.html @@ -6,7 +6,7 @@ {% trans 'List of contributors' %} ({{list_contributor|length}}) - {% trans 'Lastname / Firstname' %} + {% trans 'Last name / First name' %} {% trans 'Mail' %} {% trans 'Role' %} {% trans 'Web link' %} diff --git a/pod/completion/templates/video_completion.html b/pod/completion/templates/video_completion.html index f02ad092cd..d19c000750 100644 --- a/pod/completion/templates/video_completion.html +++ b/pod/completion/templates/video_completion.html @@ -49,6 +49,32 @@

{% if request.user.is_staff %} + {% if USE_SPEAKER %} +
+

+ +

+
+
+ {% include 'speaker/list_speaker.html' %} + + {% if form_speaker %} + {% include 'speaker/form_speaker.html' with form_speaker=form_speaker %} + {% endif %} + +
+ {% csrf_token %} + + +
+
+
+
+{% endif %} + +

+ + {% if USE_SPEAKER %} +
+ +
+

{% trans 'List of speakers related to this video.' %}

+

{% trans "You can add speakers to this video by searching by their last name, first name or job. If you can't find the speaker, contact a super admin." %}

+
+
+ {% endif %}
+
+ + + + + + diff --git a/pod/speaker/templates/speaker/speakers_management.html b/pod/speaker/templates/speaker/speakers_management.html new file mode 100644 index 0000000000..e64272e3c4 --- /dev/null +++ b/pod/speaker/templates/speaker/speakers_management.html @@ -0,0 +1,77 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} +{% load thumbnail %} + +{% block page_extra_head %} +{% endblock page_extra_head %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block page_content %} +
+ + + + + + + + + + + + + {% for speaker in speakers %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans 'List of speakers' %}
{% trans 'First name' %}{% trans 'Last name' %}{% trans 'Job' %}{% trans 'Actions' %}
{{ speaker.firstname }}{{ speaker.lastname }} +
    + {% for job in speaker.job_set.all %} +
  • {{ job.title }}
  • + {% empty %} +
  • {% trans 'No jobs assigned' %}
  • + {% endfor %} +
+
+ + {% trans "Please confirm you want to delete the speaker" as confirmDelete %} +
+ {% csrf_token %} + + + +
+
{% trans 'No speakers found.' %}
+
+ + {% include "speaker/speaker_modal.html" %} +{% endblock page_content %} + +{% block collapse_page_aside %}{% endblock collapse_page_aside %} +{% block page_aside %}{% endblock page_aside %} +{% block more_script %} + +{% endblock more_script %} + diff --git a/pod/speaker/templatetags/__init__.py b/pod/speaker/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pod/speaker/templatetags/speaker_template_tags.py b/pod/speaker/templatetags/speaker_template_tags.py new file mode 100644 index 0000000000..6f0134893f --- /dev/null +++ b/pod/speaker/templatetags/speaker_template_tags.py @@ -0,0 +1,24 @@ +"""Template tags for the speaker app.""" + +from django import template + + +from pod.speaker.utils import get_video_speakers +from pod.video.models import Video + + +register = template.Library() + + +@register.simple_tag(name="get_video_speaker") +def get_video_speaker(video: Video) -> list: + """ + Get all speaker for a video. + + Args: + video (:class:`pod.video.models.Video`): The video object + + Returns: + list (:class:`list(pod.speaker.models.VideoJob)`): The list of speakers job. + """ + return get_video_speakers(video) diff --git a/pod/speaker/tests/__init__.py b/pod/speaker/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pod/speaker/tests/test_models.py b/pod/speaker/tests/test_models.py new file mode 100644 index 0000000000..3838c8211b --- /dev/null +++ b/pod/speaker/tests/test_models.py @@ -0,0 +1,73 @@ +"""Unit tests for speaker models.""" + +from django.test import TestCase +from django.contrib.auth.models import User +from pod.video.models import Type, Video +from pod.speaker.models import Speaker, Job, JobVideo + + +class SpeakerModelTest(TestCase): + """Test case for Pod speaker models.""" + + def setUp(self): + """Set up SpeakerModel Tests.""" + owner = User.objects.create(username="pod") + videotype = Type.objects.create(title="others") + video = Video.objects.create( + title="video1", + type=videotype, + owner=owner, + video="test.mp4", + duration=20, + ) + video2 = Video.objects.create( + title="video2", + type=videotype, + owner=owner, + video="test2.mp4", + duration=20, + ) + speaker1 = Speaker.objects.create( + firstname="Dupont", + lastname="Pierre" + ) + speaker2 = Speaker.objects.create( + firstname="Martin", + lastname="Michel" + ) + job1 = Job.objects.create( + speaker=speaker1, + title="Directeur" + ) + job2 = Job.objects.create( + speaker=speaker1, + title="President" + ) + job3 = Job.objects.create( + speaker=speaker2, + title="Responsable" + ) + JobVideo.objects.create( + video=video, + job=job1 + ) + JobVideo.objects.create( + video=video, + job=job2 + ) + JobVideo.objects.create( + video=video2, + job=job3 + ) + + def test_attributs_full(self): + """Test all attributs.""" + speaker1 = Speaker.objects.get(id=1) + speaker2 = Speaker.objects.get(id=2) + job1 = Job.objects.get(id=1) + self.assertEqual(speaker1.firstname, "Dupont") + self.assertEqual(speaker1.lastname, "Pierre") + self.assertEqual(speaker2.firstname, "Martin") + self.assertEqual(speaker2.lastname, "Michel") + self.assertEqual(job1.title, "Directeur") + print(" ---> test_attributs_full: OK! --- SpeakerModelTest") diff --git a/pod/speaker/tests/test_views.py b/pod/speaker/tests/test_views.py new file mode 100644 index 0000000000..74b59ce51d --- /dev/null +++ b/pod/speaker/tests/test_views.py @@ -0,0 +1,107 @@ +"""Unit tests for speaker views.""" + +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth.models import User +from pod.speaker.models import Speaker, Job + +# ggignore-start +# gitguardian:ignore +PWD = "thisisnotpassword" +# ggignore-end + + +class SpeakerViewsTest(TestCase): + """Test case for speaker views.""" + def setUp(self): + """Set up the test environment.""" + self.client = Client() + self.superuser = User.objects.create_superuser( + username='admin', password=PWD, email='admin@example.com' + ) + self.user = User.objects.create_user( + username='user', password=PWD + ) + self.speaker = Speaker.objects.create(firstname='John', lastname='Doe') + self.job = Job.objects.create(title='Engineer', speaker=self.speaker) + + def test_speaker_management_superuser_get(self): + """Test GET request to speaker management by a superuser.""" + self.client.login(username='admin', password=PWD) + response = self.client.get(reverse('speaker:speaker_management')) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'speaker/speakers_management.html') + + def test_speaker_management_non_superuser_get(self): + """Test GET request to speaker management by a non-superuser.""" + self.client.login(username='user', password=PWD) + response = self.client.get(reverse('speaker:speaker_management')) + self.assertEqual(response.status_code, 403) + + def test_add_speaker(self): + """Test adding a new speaker.""" + self.client.login(username='admin', password=PWD) + data = { + 'action': 'add', + 'firstname': 'Jane', + 'lastname': 'Smith', + 'jobs[]': ['Teacher', 'Researcher'], + } + url = reverse('speaker:speaker_management') + response = self.client.post(url, data) + self.assertRedirects(response, reverse('speaker:speaker_management')) + self.assertTrue(Speaker.objects.filter(firstname='Jane', lastname='Smith').exists()) + speaker = Speaker.objects.get(firstname='Jane', lastname='Smith') + self.assertEqual(speaker.job_set.count(), 2) + + def test_delete_speaker(self): + """Test deleting a speaker.""" + self.client.login(username='admin', password=PWD) + data = { + 'action': 'delete', + 'speakerid': self.speaker.id, + } + response = self.client.post(reverse('speaker:speaker_management'), data) + self.assertRedirects(response, reverse('speaker:speaker_management')) + self.assertFalse(Speaker.objects.filter(id=self.speaker.id).exists()) + + def test_edit_speaker(self): + """Test editing a speaker.""" + self.client.login(username='admin', password=PWD) + data = { + 'action': 'edit', + 'speakerid': self.speaker.id, + 'firstname': 'Johnny', + 'lastname': 'Doe', + 'jobIds[]': [self.job.id], + 'jobs[]': ['Senior Engineer'], + } + response = self.client.post(reverse('speaker:speaker_management'), data) + self.assertRedirects(response, reverse('speaker:speaker_management')) + speaker = Speaker.objects.get(id=self.speaker.id) + self.assertEqual(speaker.firstname, 'Johnny') + self.assertEqual(speaker.job_set.count(), 1) + self.assertEqual(speaker.job_set.first().title, 'Senior Engineer') + + def test_get_speaker(self): + """Test retrieving a speaker's details.""" + self.client.login(username='admin', password=PWD) + response = self.client.get(reverse('speaker:get_speaker', args=[self.speaker.id])) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), { + 'speaker': { + 'id': self.speaker.id, + 'firstname': self.speaker.firstname, + 'lastname': self.speaker.lastname, + 'jobs': [{'id': self.job.id, 'title': self.job.title}], + } + }) + + def test_get_jobs(self): + """Test retrieving jobs for a speaker.""" + self.client.login(username='admin', password=PWD) + response = self.client.get(reverse('speaker:get_jobs', args=[self.speaker.id])) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), { + 'jobs': [{'id': self.job.id, 'title': self.job.title}] + }) diff --git a/pod/speaker/urls.py b/pod/speaker/urls.py new file mode 100644 index 0000000000..54bc8f97c9 --- /dev/null +++ b/pod/speaker/urls.py @@ -0,0 +1,13 @@ +"""Esup-Pod speaker urls.""" + +from django.urls import path +from pod.speaker.views import speaker_management, add_speaker, get_jobs, get_speaker + +app_name = "speaker" + +urlpatterns = [ + path("", speaker_management, name="speaker_management"), + path("add/", add_speaker, name='add_speaker'), + path('get-speaker//', get_speaker, name='get_speaker'), + path('get-jobs//', get_jobs, name='get_jobs'), +] diff --git a/pod/speaker/utils.py b/pod/speaker/utils.py new file mode 100644 index 0000000000..61a6230898 --- /dev/null +++ b/pod/speaker/utils.py @@ -0,0 +1,28 @@ +"""Esup-Pod speaker utilities.""" + +from typing import Optional +from pod.speaker.models import Speaker, JobVideo +from pod.video.models import Video + + +def get_all_speakers() -> Optional[Speaker]: + """ + Retrieve the speakers list. + + Returns: + Optional[Speakers]: The speakers list, or None if no speaker is found. + """ + return Speaker.objects.prefetch_related('job_set').all() + + +def get_video_speakers(video: Video) -> Optional[JobVideo]: + """ + Retrieve the speakers associated with a given video. + + Args: + video (Video): The video for which to retrieve the speakers. + + Returns: + Optional[JobVideo]: The jobs associated with the video, or None if no job is found. + """ + return JobVideo.objects.filter(video=video) diff --git a/pod/speaker/views.py b/pod/speaker/views.py new file mode 100644 index 0000000000..ffdbab67f3 --- /dev/null +++ b/pod/speaker/views.py @@ -0,0 +1,274 @@ +"""Esup-Pod speaker views.""" + +from django.http import HttpResponse, JsonResponse +from django.shortcuts import redirect, render, get_object_or_404 +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.decorators import login_required +from django.views.decorators.csrf import csrf_protect +from pod.main.views import in_maintenance +from .models import Speaker, Job +from pod.speaker.forms import SpeakerForm, JobForm +from pod.speaker.utils import get_all_speakers +from django.contrib import messages +from django.core.exceptions import PermissionDenied, ObjectDoesNotExist +from django.core.handlers.wsgi import WSGIRequest +from django.db import transaction + + +@csrf_protect +@login_required(redirect_field_name="referrer") +def speaker_management(request: WSGIRequest) -> HttpResponse: + """ + Manage speakers. + + This view handles the management of speakers. It checks if the system is + in maintenance mode and if the user has the appropriate permissions. + It processes speaker actions if the request method is POST. Otherwise, + it renders the speaker management page. + + Args: + request (WSGIRequest): The HTTP request object. + + Returns: + HttpResponse: The HTTP response object. + """ + if in_maintenance(): + return redirect(reverse("maintenance")) + + if not request.user.is_superuser: + messages.add_message(request, messages.ERROR, _("You cannot access speaker management.")) + raise PermissionDenied + + if request.method == "POST": + if handle_speaker_action(request): + return redirect(reverse("speaker:speaker_management")) + + speakers = get_all_speakers + speaker_form = SpeakerForm() + job_form = JobForm() + + return render( + request, + "speaker/speakers_management.html", + { + "page_title": _("Speakers management"), + "speakers": speakers, + "speaker_form": speaker_form, + "job_form": job_form, + }, + ) + + +def handle_speaker_action(request: WSGIRequest): + """ + Handle speaker actions. + + This function processes the action specified in the POST request. + It calls the appropriate function based on the action. + + Args: + request (WSGIRequest): The HTTP request object. + + Returns: + bool: True if the action was handled successfully, False otherwise. + """ + action = request.POST.get("action") + if action == "add": + return add_speaker(request) + elif action == "delete": + return delete_speaker(request) + elif action == "edit": + return edit_speaker(request) + else: + messages.add_message(request, messages.ERROR, _("An action must be specified.")) + return False + + +def add_speaker(request: WSGIRequest): + """ + Add a new speaker. + + This function adds a new speaker with the details provided in the POST request. + It also adds jobs for the speaker if specified. + + Args: + request (WSGIRequest): The HTTP request object. + + Returns: + bool: True if the speaker was added successfully, False otherwise. + """ + try: + firstname = request.POST.get('firstname') + lastname = request.POST.get('lastname') + jobs = request.POST.getlist('jobs[]') + speaker = Speaker.objects.create(firstname=firstname, lastname=lastname) + for job_title in jobs: + if job_title.strip(): + Job.objects.create(title=job_title, speaker=speaker) + messages.add_message(request, messages.SUCCESS, _("The speaker has been added.")) + return True + except (ValueError, ObjectDoesNotExist): + messages.add_message(request, messages.ERROR, _("The speaker could not be added.")) + return False + + +def delete_speaker(request: WSGIRequest): + """ + Delete a speaker. + + This function deletes a speaker and their associated jobs based on the + speaker ID provided in the POST request. + + Args: + request (WSGIRequest): The HTTP request object. + + Returns: + bool: True if the speaker was deleted successfully, False otherwise. + """ + try: + speakerid = request.POST.get('speakerid') + Speaker.objects.get(id=speakerid).delete() + messages.add_message(request, messages.SUCCESS, _("The speaker has been deleted.")) + return True + except (ValueError, ObjectDoesNotExist): + messages.add_message(request, messages.ERROR, _("The speaker could not be deleted.")) + return False + + +@login_required +def edit_speaker(request: WSGIRequest): + """ + Edit an existing speaker. + + This function edits an existing speaker and their jobs based on the + details provided in the POST request. + + Args: + request (WSGIRequest): The HTTP request object. + + Returns: + bool: True if the speaker was edited successfully, False otherwise. + """ + try: + job_ids = request.POST.getlist('jobIds[]') + job_titles = request.POST.getlist('jobs[]') + + with transaction.atomic(): + speaker = edit_speaker_details(request) + update_existing_jobs(speaker, job_ids, job_titles) + + messages.add_message(request, messages.SUCCESS, _("The speaker has been updated.")) + return True + except (ValueError, ObjectDoesNotExist) as e: + messages.add_message(request, messages.ERROR, _("Speaker not found or invalid input.")) + print(e) + return False + + +def edit_speaker_details(request: WSGIRequest): + """ + Edit speaker details. + + This function edits the details of a speaker such as their first name + and last name based on the details provided in the POST request. + + Args: + request (WSGIRequest): The HTTP request object. + + Returns: + Speaker: The updated speaker object. + """ + speakerid = request.POST.get('speakerid') + firstname = request.POST.get('firstname') + lastname = request.POST.get('lastname') + + if not speakerid or not firstname or not lastname: + raise ValueError("Missing speaker information") + + speaker = Speaker.objects.get(id=speakerid) + speaker.firstname = firstname + speaker.lastname = lastname + speaker.save() + + return speaker + + +def update_existing_jobs(speaker, job_ids, job_titles): + """ + Update existing jobs for a speaker. + + This function updates the jobs of a speaker based on the job IDs and + job titles provided in the POST request. + + Args: + speaker (Speaker): The speaker object. + job_ids (list): A list of job IDs. + job_titles (list): A list of job titles. + """ + existing_jobs = {job.id: job for job in speaker.job_set.all()} + updated_job_ids = {int(job_id) for job_id in job_ids if job_id} + + # Remove jobs that are not in the updated list + jobs_to_remove = set(existing_jobs.keys()) - updated_job_ids + for job_id in jobs_to_remove: + job = existing_jobs[job_id] + job.delete() + + # Add or update jobs + for job_id, job_title in zip(job_ids, job_titles): + job_title = job_title.strip() + if job_id: + job_id = int(job_id) + if job_id in existing_jobs: + job = existing_jobs[job_id] + job.title = job_title + job.save() + else: + if job_title: + Job.objects.create(title=job_title, speaker=speaker) + + +@login_required +def get_speaker(request, speaker_id) -> JsonResponse: + """ + Get details of a specific speaker. + + This function retrieves the details of a specific speaker including + their jobs based on the speaker ID. + + Args: + speaker_id (int): The ID of the speaker. + + Returns: + JsonResponse: A JSON response containing the speaker details. + """ + speaker = get_object_or_404(Speaker, id=speaker_id) + jobs = speaker.job_set.all().values('id', 'title') + speaker_data = { + 'id': speaker.id, + 'firstname': speaker.firstname, + 'lastname': speaker.lastname, + 'jobs': list(jobs) + } + return JsonResponse({'speaker': speaker_data}) + + +@login_required +def get_jobs(request, speaker_id) -> JsonResponse: + """ + Get jobs of a specific speaker. + + This function retrieves the jobs of a specific speaker based on + the speaker ID. + + Args: + speaker_id (int): The ID of the speaker. + + Returns: + JsonResponse: A JSON response containing the jobs. + """ + speaker = get_object_or_404(Speaker, id=speaker_id) + jobs = speaker.job_set.all().values('id', 'title') + jobs_list = list(jobs) + return JsonResponse({'jobs': jobs_list}) diff --git a/pod/urls.py b/pod/urls.py index 04e84aa7f1..a63ca7be84 100644 --- a/pod/urls.py +++ b/pod/urls.py @@ -36,6 +36,7 @@ USE_PODFILE = getattr(settings, "USE_PODFILE", False) USE_PLAYLIST = getattr(settings, "USE_PLAYLIST", True) USE_DRESSING = getattr(settings, "USE_DRESSING", True) +USE_SPEAKER = getattr(settings, "USE_SPEAKER", False) USE_IMPORT_VIDEO = getattr(settings, "USE_IMPORT_VIDEO", True) USE_QUIZ = getattr(settings, "USE_QUIZ", True) USE_AI_ENHANCEMENT = getattr(settings, "USE_AI_ENHANCEMENT", False) @@ -191,6 +192,12 @@ path("dressing/", include("pod.dressing.urls", namespace="dressing")), ] +# SPEAKER +if USE_SPEAKER: + urlpatterns += [ + path("speaker/", include("pod.speaker.urls", namespace="speaker")), + ] + # IMPORT_VIDEO if USE_IMPORT_VIDEO: urlpatterns += [ diff --git a/pod/video/templates/videos/video-info.html b/pod/video/templates/videos/video-info.html index f38d30a0ab..1e7934ffcc 100644 --- a/pod/video/templates/videos/video-info.html +++ b/pod/video/templates/videos/video-info.html @@ -5,6 +5,7 @@ {% load video_tags %} {% load playlist_stats %} {% load favorites_playlist %} +{% load speaker_template_tags %}
@@ -170,6 +171,21 @@

 {%

{% endif %} + {% if USE_SPEAKER %} +
  • + {% trans 'Speaker(s):' %} +
    +
      + {% get_video_speaker video as speaker_in_video %} + {% for speaker in speaker_in_video %} +
    • + {{ speaker.job.speaker.firstname }} {{ speaker.job.speaker.lastname }} ({{ speaker.job.title }}) +
    • + {% endfor %} +
    +
    +
  • + {% endif %}
  • {% trans 'Updated on:' %} From fd1a1bb46ec51c2d8a69def47a8886ac2fbffe03 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 10 Jul 2024 10:07:38 +0000 Subject: [PATCH 11/24] Fixup. Format code with Black --- pod/completion/views.py | 4 +- pod/speaker/admin.py | 14 ++-- pod/speaker/apps.py | 4 +- pod/speaker/forms.py | 14 ++-- pod/speaker/models.py | 21 ++---- pod/speaker/tests/test_models.py | 40 +++--------- pod/speaker/tests/test_views.py | 106 ++++++++++++++++--------------- pod/speaker/urls.py | 6 +- pod/speaker/utils.py | 2 +- pod/speaker/views.py | 58 ++++++++++------- 10 files changed, 128 insertions(+), 141 deletions(-) diff --git a/pod/completion/views.py b/pod/completion/views.py index 23ec95a61e..2d2db01bf5 100644 --- a/pod/completion/views.py +++ b/pod/completion/views.py @@ -532,9 +532,7 @@ def video_completion_speaker_save(request: WSGIRequest, video: Video): data = json.dumps(some_data_to_dump) return HttpResponse(data, content_type="application/json") else: - context = get_video_completion_context( - video, list_speaker=list_speaker - ) + context = get_video_completion_context(video, list_speaker=list_speaker) context["page_title"] = get_completion_home_page_title(video) messages.add_message( request, messages.SUCCESS, _("The speaker has been saved.") diff --git a/pod/speaker/admin.py b/pod/speaker/admin.py index 70b428cb4c..70918fcaa5 100644 --- a/pod/speaker/admin.py +++ b/pod/speaker/admin.py @@ -16,7 +16,7 @@ class JobInline(admin.StackedInline): class SpeakerAdmin(admin.ModelAdmin): """Admin configuration for Speaker.""" - list_display = ('firstname', 'lastname') + list_display = ("firstname", "lastname") inlines = [JobInline] @@ -24,15 +24,15 @@ class SpeakerAdmin(admin.ModelAdmin): class JobAdmin(admin.ModelAdmin): """Admin configuration for Job.""" - list_display = ('title', 'speaker') - list_filter = ('speaker',) - search_fields = ('title', 'speaker__firstname', 'speaker__lastname') + list_display = ("title", "speaker") + list_filter = ("speaker",) + search_fields = ("title", "speaker__firstname", "speaker__lastname") @admin.register(JobVideo) class JobVideoAdmin(admin.ModelAdmin): """Admin configuration for Video speaker by job.""" - list_display = ('job', 'video') - list_filter = ('job', 'video') - search_fields = ('job__title', 'video__title') + list_display = ("job", "video") + list_filter = ("job", "video") + search_fields = ("job__title", "video__title") diff --git a/pod/speaker/apps.py b/pod/speaker/apps.py index 90a18ed985..fb7139b823 100644 --- a/pod/speaker/apps.py +++ b/pod/speaker/apps.py @@ -7,6 +7,6 @@ class SpeakerConfig(AppConfig): """Speaker config app.""" - name = 'pod.speaker' - default_auto_field = 'django.db.models.BigAutoField' + name = "pod.speaker" + default_auto_field = "django.db.models.BigAutoField" verbose_name = _("Speaker") diff --git a/pod/speaker/forms.py b/pod/speaker/forms.py index aa3f00267d..409ba4ee2f 100644 --- a/pod/speaker/forms.py +++ b/pod/speaker/forms.py @@ -12,7 +12,9 @@ class JobWidget(s2forms.ModelSelect2Widget): """Widget for selecting speaker job.""" search_fields = [ - "title__icontains", "speaker__lastname__icontains", "speaker__firstname__icontains", + "title__icontains", + "speaker__lastname__icontains", + "speaker__firstname__icontains", ] @@ -21,7 +23,7 @@ class SpeakerForm(forms.ModelForm): class Meta: model = Speaker - fields = ['firstname', 'lastname'] + fields = ["firstname", "lastname"] def __init__(self, *args, **kwargs): """Init method.""" @@ -34,7 +36,7 @@ class JobForm(forms.ModelForm): class Meta: model = Job - fields = ['title'] + fields = ["title"] class JobVideoForm(forms.ModelForm): @@ -53,8 +55,10 @@ class Meta(object): widgets = { "job": JobWidget( attrs={ - 'data-placeholder': _("You can search speaker by first name, last name and job."), - 'class': 'w-100', + "data-placeholder": _( + "You can search speaker by first name, last name and job." + ), + "class": "w-100", } ) } diff --git a/pod/speaker/models.py b/pod/speaker/models.py index b07cf01a58..c9c5e1859f 100644 --- a/pod/speaker/models.py +++ b/pod/speaker/models.py @@ -14,14 +14,8 @@ class Speaker(models.Model): lastname (CharField): last name of speaker. """ - firstname = models.CharField( - verbose_name=_("First name"), - max_length=100 - ) - lastname = models.CharField( - verbose_name=_("Last name"), - max_length=100 - ) + firstname = models.CharField(verbose_name=_("First name"), max_length=100) + lastname = models.CharField(verbose_name=_("Last name"), max_length=100) class Meta: ordering = ["lastname", "firstname"] @@ -41,10 +35,7 @@ class Job(models.Model): speaker (ForeignKey ): Speaker of the job. """ - title = models.CharField( - verbose_name=_("Title"), - max_length=100 - ) + title = models.CharField(verbose_name=_("Title"), max_length=100) speaker = models.ForeignKey(Speaker, on_delete=models.CASCADE) def __str__(self): @@ -60,11 +51,13 @@ class JobVideo(models.Model): video (ForeignKey
  • - + diff --git a/pod/video/static/css/video_category.css b/pod/video/static/css/video_category.css index 9947ee0c49..9602265b9e 100644 --- a/pod/video/static/css/video_category.css +++ b/pod/video/static/css/video_category.css @@ -45,7 +45,7 @@ } /* Override modal category css */ -#manageCategoryModal.show { +#category-modal.show { display: flex !important; justify-content: center !important; align-items: flex-start !important; @@ -53,7 +53,7 @@ margin: 0 !important; } -#manageCategoryModal .modal-dialog { +#category-modal .modal-dialog { width: 900px; max-width: calc(100% - 2em); margin: 0 auto; @@ -61,13 +61,13 @@ transform: translateY(-50%); } -#manageCategoryModal .category_modal_videos_list { +.category-modal-videos-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); grid-gap: 0.4em; } -#manageCategoryModal .category_modal_videos_list .infinite-item { +.category-modal-videos-list .infinite-item { position: relative; padding: 0.2em 0.4em; min-width: 133px; @@ -75,7 +75,7 @@ transition: 0.3s all; } -.category_modal_videos_list .infinite-item .checked_overlay { +.category-modal-videos-list .infinite-item .checked-overlay { display: flex; position: absolute; margin: 0.2em 0.4em; @@ -91,7 +91,7 @@ transition: opacity 0.3s; } -#manageCategoryModal .category_modal_videos_list .infinite-item .card_selected { +.category-modal-videos-list .infinite-item .card-selected { display: block; width: 25%; font-size: 2em; @@ -99,47 +99,19 @@ transition: all 0.3s; } -#manageCategoryModal - .category_modal_videos_list - .infinite-item:not(.selected):hover, -#manageCategoryModal - .category_modal_videos_list - .infinite-item - .checked_overlay:hover - .card_selected { - transform: scale(1.04); -} - -#manageCategoryModal - .category_modal_videos_list +#category-modal + .category-modal-videos-list .infinite-item.selected - .checked_overlay { + .checked-overlay { opacity: 1; } -#manageCategoryModal .category_modal_videos_list .modal_category_card { - margin-bottom: 0.4em !important; - height: 100%; - border-radius: var(--bs-border-radius-sm); -} - -#manageCategoryModal .category_modal_videos_list .card-header { - background-color: var(--pod-primary); - color: white; - font-size: 14px; - padding: 0.4em; - border-top-left-radius: var(--bs-border-radius-sm); -} - -#manageCategoryModal .category_modal_videos_list .card-header * { - color: inherit !important; -} - -#manageCategoryModal .category_modal_videos_list .card-footer { +#category-modal .category-modal-videos-list .card-header, +#category-modal .category-modal-videos-list .card-footer { padding: 0.4em; } -#manageCategoryModal .category_modal_videos_list .card-footer .video_title { +#category-modal .category-modal-videos-list .card-footer .video-title { display: inline-block; line-height: 1; height: 100%; @@ -152,7 +124,7 @@ color: var(--pod-primary); } -#deleteCategoryModal .modal-body .category_title { +.category-title { display: block; text-align: center; color: var(--pod-primary); @@ -160,12 +132,12 @@ font-weight: 700; } -.categories_list { +.categories-list { padding: 0; padding-left: 0.4em; } -.categories_list_item { +.categories-list-item { list-style: square; padding: 0.4em; line-height: 1; @@ -178,38 +150,38 @@ transition: 0.3s all; } -.categories_list_item.active { +.categories-list-item.active { color: #fff; background-color: var(--pod-primary); } -.active .category_actions > button { +.active .category-actions > button { color: #fff; } -.categories_list_item:last-child { +.categories-list-item:last-child { border: none; } -.categories_list_item:not(.active):hover { +.categories-list-item:not(.active):hover { background-color: rgb(149 149 149 / 9%); border-color: rgb(149 149 149 / 9%); } -.categories_list_item.active .cat_title { +.categories-list-item.active .cat-title { color: inherit; } -.edit_category { +.edit-category { --bs-btn-hover-color: var(--pod-primary-darken); } -.remove_category { +.remove-category { --bs-btn-hover-color: var(--bs-danger); } /* Override Paginator css */ -.category_modal_videos_list.show { +.category-modal-videos-list.show { height: 385px !important; } @@ -221,7 +193,15 @@ grid-column: 1 / -1; } -.category_modal_videos_list.show .paginator, -.loader_wrapper.show { +.category-modal-videos-list .paginator, +.loader-wrapper.show { display: flex; } + +#category-modal-videos-list .video-card > .card-thumbnail{ + min-height: auto; +} + +#category-modal-videos-list .pod-thumbnail{ + width: 100%; +} diff --git a/pod/video/static/js/change_video_owner.js b/pod/video/static/js/change_video_owner.js index 93a152096d..629aeff807 100644 --- a/pod/video/static/js/change_video_owner.js +++ b/pod/video/static/js/change_video_owner.js @@ -25,9 +25,9 @@ ".paginator #previous_content", ); - const submitBTN = document.querySelector("#submitChanges"); + const submitBTN = document.querySelector("#submit-changes"); - const selectAllVideos = document.getElementById("select_all"); + const selectAllVideos = document.getElementById("select-all"); let new_username_id = null; diff --git a/pod/video/static/js/dashboard.js b/pod/video/static/js/dashboard.js index 0e6d39315c..fb517e69b2 100644 --- a/pod/video/static/js/dashboard.js +++ b/pod/video/static/js/dashboard.js @@ -18,9 +18,14 @@ global urlUpdateVideos csrftoken formFieldsets displayMode */ +// Read-only globals defined in video_select.js +/* + global selectedVideos +*/ + /* exported dashboardActionReset */ -var bulkUpdateActionSelect = document.getElementById("bulkUpdateActionSelect"); +var bulkUpdateActionSelect = document.getElementById("bulk-update-action-select"); var applyBulkUpdateBtn = document.getElementById("applyBulkUpdateBtn"); var resetDashboardElementsBtn = document.getElementById( "reset-dashboard-elements-btn", @@ -32,6 +37,7 @@ var cancelModalBtn = document.getElementById("cancelModalBtn"); var btnDisplayMode = document.querySelectorAll(".btn-dashboard-display-mode"); var dashboardAction = ""; var dashboardValue; +selectedVideos[videosListContainerId] = []; /** * Add change event listener on select action to get related inputs @@ -39,15 +45,14 @@ var dashboardValue; bulkUpdateActionSelect.addEventListener("change", function () { dashboardAction = bulkUpdateActionSelect.value; appendDynamicForm(dashboardAction); - replaceSelectedCountVideos(); - manageDisableBtn(resetDashboardElementsBtn, dashboardAction != ""); + replaceSelectedCountVideos(videosListContainerId); }); /** * Add click event listener on apply button to build and open confirm modal */ applyBulkUpdateBtn.addEventListener("click", () => { - let selectedCount = selectedVideos.length; + let selectedCount = selectedVideos[videosListContainerId].length; let modalEditionConfirmStr = ngettext( "Please confirm the editing of the following video:", "Please confirm the editing of the following videos:", @@ -71,7 +76,7 @@ applyBulkUpdateBtn.addEventListener("click", () => { true, ); modal.querySelector(".modal-body").innerHTML = - "

    " + modalConfirmStr + "

    " + getHTMLBadgesSelectedTitles(); + "

    " + modalConfirmStr + "

    " + getHTMLBadgesSelectedTitles(videosListContainerId); }); /** @@ -152,7 +157,7 @@ async function bulkUpdate() { } // Construct formData to send - formData.append("selected_videos", JSON.stringify(selectedVideos)); + formData.append("selected_videos", JSON.stringify(selectedVideos[videosListContainerId])); formData.append("update_fields", JSON.stringify(updateFields)); formData.append("update_action", updateAction); @@ -173,9 +178,10 @@ async function bulkUpdate() { if (response.ok) { // Set selected videos with new slugs if changed during update - selectedVideos = data["updated_videos"]; - showalert(message, "alert-success", "formalertdivbottomright"); + selectedVideos[videosListContainerId] = data["updated_videos"]; + showalert(message, "alert-success", "form-alert-div-bottom-right"); refreshVideosSearch(); + replaceSelectedCountVideos(videosListContainerId); } else { // Manage field errors and global errors let errors = Array.from(data["fields_errors"]); @@ -190,7 +196,7 @@ async function bulkUpdate() { }); window.scroll({ top: 0, left: 0, behavior: "smooth" }); } else { - showalert(message, "alert-danger", "formalertdivbottomright"); + showalert(message, "alert-danger", "form-alert-div-bottom-right"); } } } diff --git a/pod/video/static/js/filter_aside_video_list_refresh.js b/pod/video/static/js/filter_aside_video_list_refresh.js index acb55bbefb..0d7792692d 100644 --- a/pod/video/static/js/filter_aside_video_list_refresh.js +++ b/pod/video/static/js/filter_aside_video_list_refresh.js @@ -28,10 +28,10 @@ function onBeforePageLoad() { function onAfterPageLoad() { if ( urlVideos === "/video/dashboard/" && - selectedVideos && - selectedVideos.length !== 0 + selectedVideos[videosListContainerId] && + selectedVideos[videosListContainerId].length !== 0 ) { - setSelectedVideos(); + setSelectedVideos(videosListContainerId); } infiniteLoading.style.display = "none"; let footer = document.querySelector("footer.static-pod"); @@ -127,10 +127,10 @@ function refreshVideosSearch() { } if ( urlVideos === "/video/dashboard/" && - selectedVideos && - selectedVideos.length !== 0 + selectedVideos[videosListContainerId] && + selectedVideos[videosListContainerId].length !== 0 ) { - setSelectedVideos(); + setSelectedVideos(videosListContainerId); } }) .catch(() => { @@ -174,11 +174,11 @@ function getUrlForRefresh() { if (urlVideos === "/video/dashboard/" && displayMode !== undefined) { newUrl += "display_mode=" + displayMode + "&"; } - // Add category checked if exists - if (document.querySelectorAll(".categories_list_item.active").length !== 0) { - checkedCategory = document.querySelector(".categories_list_item.active") - .firstChild["dataset"]["slug"]; - newUrl += "category=" + checkedCategory + "&"; + // Add categories checked if exists + if (document.querySelectorAll(".categories-list-item.active").length !== 0) { + Array.from(document.querySelectorAll(".categories-list-item.active")).forEach((cat) => { + newUrl += "categories=" + cat.firstElementChild["dataset"]["slug"] + "&"; + }); } // Add all other parameters (filters) checkedInputs.forEach((input) => { @@ -266,7 +266,7 @@ document.getElementById("resetFilters").addEventListener("click", function () { .forEach((checkBox) => { checkBox.checked = false; }); - document.querySelectorAll("#filters .categories_list_item").forEach((c_p) => { + document.querySelectorAll("#filters .categories-list-item").forEach((c_p) => { c_p.classList.remove("active"); }); if (filterOwnerContainer && ownerBox) { diff --git a/pod/video/static/js/video_category.js b/pod/video/static/js/video_category.js index 1d5bc4b61a..ec491be2ae 100644 --- a/pod/video/static/js/video_category.js +++ b/pod/video/static/js/video_category.js @@ -1,912 +1,251 @@ /** * Esup-Pod video category scripts. */ +const catVideosListContainerId = "category-modal-videos-list"; +var currentUrl; +selectedVideos[catVideosListContainerId] = []; -/* Read-only globals defined in filter_aside_video_list_refresh.js and my_videos.html */ +/* Read-only globals defined in dashboard.html */ /* -global refreshVideosSearch, CATEGORIES_DATA, BASE_URL, VIDEO_URL, EDIT_URL, - COMPLETION_URL, CHAPTER_URL, DELETE_URL +global refreshVideosSearch, CATEGORIES_ADD_URL, CATEGORIES_EDIT_URL, CATEGORIES_DELETE_URL, + CATEGORIES_LIST_URL, EDIT_URL, COMPLETION_URL, CHAPTER_URL, DELETE_URL, VIDEO_URL, + categoriesListContainer, categoryModal */ -(function (CATEGORIES_DATA) { - const SERVER_DATA = CATEGORIES_DATA.filter((c) => !Number.isInteger(c)); - // Category to delete - const CAT_TO_DELETE = { - html: undefined, - id: undefined, - slug: undefined, - }; - const VIDEOS_LIST_CHUNK = { - videos: { - chunk: [], // all videos chunked - selected: [], // current selected videos - unselected: [], // current unselected videos - }, // all videos - page_index: 0, // current index - size: 12, // videos per page - }; - const modal_video_list = document.querySelector( - ".category_modal_videos_list", - ); - const msg_saved = gettext("Category changes saved successfully"); - const msg_error_duplicate = gettext( - "You cannot add two categories with the same title.", - ); - const msg_deleted = gettext("Category deleted successfully"); - const msg_error = gettext( - "An error occured, please refresh the page and try again.", - ); - const msg_title_empty = gettext("Category title field is required."); - const msg_save_cat = gettext("Save category"); - let videos_list = document.querySelector( - "#videos_list.infinite-container:not(.filtered)", - ); - let saveCatBtn = document.querySelector("#manageCategoryModal #saveCategory"); // btn in dialog - let modal_title = document.querySelector("#manageCategoryModal #modal_title"); - let cat_input = document.querySelector("#manageCategoryModal #catTitle"); - let CURR_CATEGORY = {}; // current editing category (js object) - let DOMCurrentEditCat = null; // current editing category (html DOM) - // show loader - const loader = document.getElementById("videosListLoader"); - const SAVED_DATA = {}; // To prevent too many requests to the server - const CURR_FILTER = { slug: null, id: null }; // the category currently filtering - const HEADERS = { - "Content-Type": "application/json", - "X-Requested-With": "XMLHttpRequest", - "X-CSRFToken": Cookies.get("csrftoken"), - Accept: "application/json", - }; - - // show paginate video - const show_paginate_videos = (paginator = true) => { - if (paginator) modal_video_list.classList.add("show"); - const html_paginator = modal_video_list.querySelector(".paginator"); - modal_video_list.textContent = ""; - modal_video_list.appendChild(html_paginator); - if (VIDEOS_LIST_CHUNK.videos.chunk.length > 0) { - let videos_to_display = - VIDEOS_LIST_CHUNK.videos.chunk[VIDEOS_LIST_CHUNK.page_index]; - videos_to_display.forEach((v) => appendVideoCard(toggleSelectedClass(v))); - } else { - const cat_modal_alert = document.createElement("div"); - cat_modal_alert.setAttribute("class", "alert alert-warning"); - cat_modal_alert.textContent = gettext( - "You have no content without a category.", - ); - const cat_modal_body = document.querySelector( - "#manageCategoryModal .modal-body", - ); - cat_modal_body.appendChild(cat_modal_alert); - } - }; - // Chunk array - const chunk = (arr, size) => - Array.from({ length: Math.ceil(arr.length / size) }, (v, i) => - arr.slice(i * size, i * size + size), - ); - const paginate = (cat_videos) => { - VIDEOS_LIST_CHUNK.page_index = 0; - prev.classList.add("disabled"); - const video_elements = Array.from( - modal_video_list.querySelectorAll( - ".category_modal_videos_list .infinite-item", - ), - ); - VIDEOS_LIST_CHUNK.videos.selected = cat_videos.map((v) => - getModalVideoCard(v), - ); - - // Saving unselected videos - if (video_elements.length && !VIDEOS_LIST_CHUNK.videos.unselected.length) { - VIDEOS_LIST_CHUNK.videos.unselected = video_elements.filter( - (html_v) => !html_v.classList.contains("selected"), - ); - } - VIDEOS_LIST_CHUNK.videos.chunk = chunk( - [ - ...VIDEOS_LIST_CHUNK.videos.unselected, - ...VIDEOS_LIST_CHUNK.videos.selected, - ], - VIDEOS_LIST_CHUNK.size, - ); - pages_info.innerText = `${VIDEOS_LIST_CHUNK.page_index + 1}/${ - VIDEOS_LIST_CHUNK.videos.chunk.length - }`; - pages_info.setAttribute( - "title", - `${VIDEOS_LIST_CHUNK.page_index + 1}/${ - VIDEOS_LIST_CHUNK.videos.chunk.length - }`, - ); - if (VIDEOS_LIST_CHUNK.videos.chunk.length > 1) { - modal_video_list.classList.add("show"); - modal_video_list - .querySelector(".next_content") - .classList.remove("disabled"); - show_paginate_videos(); - } else { - show_paginate_videos(false); - modal_video_list.classList.remove("show"); - } - }; +/** + * Manage add category link in filter aside + */ +document.getElementById("add-category-btn").addEventListener("click", () => { + get_category_modal(CATEGORIES_ADD_URL); +}); - // Add event to paginate - let pages_info = document.querySelector(".paginator .pages_infos"); - let next = document.querySelector(".paginator .next_content"); - let prev = document.querySelector(".paginator .previous_content"); - prev.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - VIDEOS_LIST_CHUNK.page_index -= VIDEOS_LIST_CHUNK.page_index > 0 ? 1 : 0; - let nbr_pages = VIDEOS_LIST_CHUNK.videos.chunk.length - 1; - if (VIDEOS_LIST_CHUNK.page_index === 0) prev.classList.add("disabled"); - next.classList.remove("disabled"); - pages_info.innerText = `${VIDEOS_LIST_CHUNK.page_index + 1}/${ - nbr_pages + 1 - }`; - pages_info.setAttribute( - "title", - `${VIDEOS_LIST_CHUNK.page_index + 1}/${nbr_pages + 1}`, - ); - show_paginate_videos(); - }); - next.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - let nbr_pages = VIDEOS_LIST_CHUNK.videos.chunk.length - 1; - VIDEOS_LIST_CHUNK.page_index += - VIDEOS_LIST_CHUNK.page_index < nbr_pages ? 1 : 0; - if (VIDEOS_LIST_CHUNK.page_index === nbr_pages) - next.classList.add("disabled"); +/** + * Manage categories links (edit and delete in filter aside) + */ +function manageCategoriesLinks(){ + Array.from(document.getElementsByClassName("edit-category-btn")).forEach((el) => { + el.addEventListener("click", () => { + let url_edit = getCategoriesUrl("edit", el.dataset.slug); + get_category_modal(url_edit); + }); + }); + Array.from(document.getElementsByClassName("delete-category-btn")).forEach((el) => { + el.addEventListener("click", () => { + let url_delete = getCategoriesUrl("delete", el.dataset.slug); + get_category_modal(url_delete); + }); + }); +} - prev.classList.remove("disabled"); - pages_info.innerText = `${VIDEOS_LIST_CHUNK.page_index + 1}/${ - nbr_pages + 1 - }`; - pages_info.setAttribute( - "title", - `${VIDEOS_LIST_CHUNK.page_index + 1}/${nbr_pages + 1}`, - ); - show_paginate_videos(); - }); +/** + * Manage search category input in filter aside + */ +let searchCategoriesInput = document.getElementById("search-categories-input"); +if(searchCategoriesInput){ + searchCategoriesInput.addEventListener("input", () => { + manageSearchCategories(searchCategoriesInput.value.trim()); + }); +} - // Search categery - let searchCatInput = document.getElementById("searchcategories"); - let searchCatHandler = (s) => { - let cats = document.querySelectorAll( - ".categories_list .cat_title:not(.hidden)", +/** + * Manage search category input to display chosen ones + * + * @param search {string} : Search input string + */ +function manageSearchCategories(search){ + let categories = document.querySelectorAll( + ".categories-list .cat-title:not(.hidden)", ); - if (s.length >= 3) { - cats.forEach((cat) => { - if (!cat.innerHTML.trim().toLowerCase().includes(s)) + if (search.length >= 3) { + categories.forEach((cat) => { + if (!cat.innerHTML.trim().toLowerCase().includes(search)) cat.parentNode.classList.add("hidden"); else cat.parentNode.classList.remove("hidden"); }); } else { - cats = document.querySelectorAll(".categories_list .hidden"); - cats.forEach((cat) => { + categories = document.querySelectorAll(".categories-list .hidden"); + categories.forEach((cat) => { cat.classList.remove("hidden"); }); } - }; - searchCatInput.addEventListener("input", () => { - searchCatHandler(searchCatInput.value.trim()); - }); +} - // Update text 'Number video found' on filtering - let manageNumberVideoFoundText = (v_len) => { - let text = v_len > 1 ? gettext("videos found") : gettext("video found"); - let h2 = document.querySelector(".pod-mainContent h2"); - h2.textContent = `${v_len} ${text}`; - if (!v_len) { - text = gettext("Sorry, no video found"); - getVideosFilteredContainer().innerHTML = ``; - } else { - // delete warning text videos found - let warning = document.querySelector( - "#videos_list.filtered .alert-warning", - ); - if (warning) warning.parentNode.removeChild(warning); - } - }; - - // Add/Remove active class on category, html_el =
  • - let manageCssActiveClass = (html_el) => { - html_el.parentNode - .querySelectorAll(".categories_list_item") - .forEach((c_p) => { - c_p.classList.remove("active"); - }); - let curr_slug = html_el.querySelector(".cat_title").dataset.slug; - let curr_id = html_el.querySelector(".remove_category").dataset.del; - html_el.classList.toggle("active"); - getVideosFilteredContainer().textContent = ""; - if (CURR_FILTER.slug === curr_slug && CURR_FILTER.id == curr_id) { - html_el.classList.remove("active"); // unfilter - CURR_FILTER.slug = null; - CURR_FILTER.id = null; - getVideosFilteredContainer().classList.add("hidden"); - if (videos_list) { - videos_list.setAttribute( - "class", - "pod-infinite-container infinite-container", - ); - } - } else { - // filter - html_el.classList.add("active"); - let { id, slug } = findCategory(curr_slug, curr_id); - CURR_FILTER.id = id; - CURR_FILTER.slug = slug; - - getVideosFilteredContainer().classList.remove("hidden"); +/** + * Toggle category links and filter dashboard's videos list with given categories + * + * @param el {HTMLElement} : Category link clicked + */ +function toggleCategoryLink(el) { + el.parentNode.classList.toggle("active"); + refreshVideosSearch(); +} - if (videos_list) { - videos_list.setAttribute( - "class", - "pod-infinite-container infinite-container hidden", - ); - } +/** + * Build and return url for Get or Post categories methods + * + * @param action {string} : Action defined "add", "edit" or "delete" + * @param slug {string} : Category slug given for edit or delete (can be null) + * @returns {string} : Returns built URL + */ +function getCategoriesUrl(action, slug = null){ + let url; + switch (action){ + case "add": + url = CATEGORIES_ADD_URL; + break; + case "edit": + url = CATEGORIES_EDIT_URL + slug + "/"; + break; + case "delete": + url = CATEGORIES_DELETE_URL + slug + "/"; + break; + default: + url = ""; } - }; + return url +} - // Create filtered videos container (HtmlELement) - let getVideosFilteredContainer = () => { - let videos_list_filtered = document.querySelector( - ".pod-mainContent .filtered.infinite-container", - ); - if (videos_list_filtered) { - return videos_list_filtered; +/** + * Async call to create Add, Edit or Delete Modal for categories managment + * + * @param url {string} : Url to call (add, edit, delete) + * @param page {number} : Page url managment (can be null) + */ +function get_category_modal(url, page=null){ + if(page){ + url += "?page=" + page; } - videos_list_filtered = document.createElement("div"); - videos_list_filtered.setAttribute( - "class", - "filtered infinite-container pod-infinite-container", - ); - videos_list_filtered.setAttribute("id", "videos_list"); - document - .querySelector(".pod-mainContent .pod-first-content") - .appendChild(videos_list_filtered); - return videos_list_filtered; - }; - // Update videos Filtered in filtered container after editing category - let updateFilteredVideosContainer = (category_data) => { - if ( - CURR_FILTER.slug && - CURR_FILTER.id && - CURR_CATEGORY.slug === CURR_FILTER.slug && - CURR_CATEGORY.id === CURR_FILTER.id - ) { - let actual_videos = VIDEOS_LIST_CHUNK.videos.selected.map( - (v_html) => v_html.dataset.slug, - ); - let old_videos = getSavedData(CURR_FILTER.id).videos.map((v) => v.slug); - let rm = old_videos.filter((v) => !actual_videos.includes(v)); - let added = actual_videos.filter((v) => !old_videos.includes(v)); - let container_filtered = getVideosFilteredContainer(); - - let maxLen = rm.length > added.length ? rm.length : added.length; - for (let i = 0; i < maxLen; i++) { - // remove video that was deselected when editing category - if (rm[i]) { - container_filtered.removeChild( - container_filtered.querySelector( - `.infinite-item[data-slug="${rm[i]}"`, - ), - ); - } - - // Add video that was selected when editing category - if (added[i]) { - container_filtered.appendChild( - getVideoElement( - category_data.videos.find((v) => v.slug === added[i]), - ), - ); - } + fetch(url, { + method: "GET", + headers: { + "X-CSRFToken": "{{ csrf_token }}", + "X-Requested-With": "XMLHttpRequest", + }, + cache: "no-store", + }) + .then((response) => response.text()) + .then((data) => { + // Parse data into html and create new modal + let parser = new DOMParser(); + let html = parser.parseFromString(data, "text/html").body; + if(page){ + document.getElementById("category-modal-videos-list").outerHTML = html.innerHTML; + document.querySelectorAll("#category-modal-videos-list .card-select-input:checked").forEach((el) => { + if(!selectedVideos[catVideosListContainerId].includes(el.dataset.slug)){ + el.checked = false; + } + }); + setSelectedVideos(catVideosListContainerId); + url = url.replaceAll(/([?]page=)(\d+)/g, ""); + }else{ + categoryModal.innerHTML = html.innerHTML; + new bootstrap.Modal(document.getElementById('category-modal')).toggle(); + manageModalConfirmBtn(); } - manageNumberVideoFoundText(actual_videos.length); - } - }; - - // Add click event to manage filter video on click category - let manageFilterVideos = (c) => { - c.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - let cat_filter_slug = c.dataset.slug.trim(); - let cat_filter_id = c.parentNode - .querySelector(".category_actions .remove_category") - .dataset.del.trim(); - manageCssActiveClass(c.parentNode); // manage active css class - refreshVideosSearch(); - }); - }; - - // remove all current selected videos in dialog - let refreshDialog = () => { - let videos = document.querySelectorAll( - ".category_modal_videos_list .selected", - ); - videos.forEach((v) => { - v.parentNode.removeChild(v); + if(url.includes(CATEGORIES_EDIT_URL)){ + let c_slug = url.split("/")[url.split("/").length - 2]; + selectedVideos[catVideosListContainerId] = all_categories_videos[c_slug]; + } + currentUrl = url; + }) + .catch(() => { + showalert(gettext("An Error occurred while processing."), "alert-danger", "form-alert-div-bottom-right"); }); - }; - - // Save/update category data locally - let saveCategoryData = (data) => { - SAVED_DATA[`${data.id}`] = data; - }; - - // Delete category data locally - let deleteFromSavedData = (c_slug) => { - if (Object.keys(SAVED_DATA).includes(c_slug)) delete SAVED_DATA[id]; - }; +} - // Search cat by slug - let findCategory = (slug, id = 0) => { - let cat = SERVER_DATA.find((c) => c.slug === slug && c.id == id); - if (!cat) cat = getSavedData((id = id)); - return cat; - }; - - // Get saved category data - let getSavedData = (id) => { - let cat = {}; - if (Object.keys(SAVED_DATA).includes(id.toString(10))) - cat = SAVED_DATA[id.toString(10)]; - return cat; - }; - - // Add event toggle selected class on el - let toggleSelectedClass = (el) => { - if (el.dataset.hasevent != "true") { - el.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - el.classList.toggle("selected"); - let selected = el.classList.contains("selected"); - if (selected) { - VIDEOS_LIST_CHUNK.videos.unselected = - VIDEOS_LIST_CHUNK.videos.unselected.filter( - (v) => !v.classList.contains("selected"), - ); - VIDEOS_LIST_CHUNK.videos.selected = [ - ...VIDEOS_LIST_CHUNK.videos.selected, - el, - ]; - } else { - VIDEOS_LIST_CHUNK.videos.selected = - VIDEOS_LIST_CHUNK.videos.selected.filter((v) => - v.classList.contains("selected"), - ); - VIDEOS_LIST_CHUNK.videos.unselected = [ - ...VIDEOS_LIST_CHUNK.videos.unselected, - el, - ]; - } - }); - el.setAttribute("data-hasevent", true); - } - return el; - }; - - // Make requets => get category data - let fetchCategoryData = async (cat_slug) => { - try { - let resp = await fetch(`${BASE_URL}${cat_slug}/`, { headers: HEADERS }); - return await resp.json(); - } catch (e) { - showLoader(loader, false); - showAlertMessage(msg_error, false, (delay = 30000)); - } - }; - - // Make post request. for edit or add category, postData(object) - let postCategoryData = async (url, postData) => { - try { - let resp = await fetch(url, { - method: "POST", - body: JSON.stringify(postData), - headers: HEADERS, - }); - return await resp.json(); - } catch (e) { - showLoader(loader, false); - showAlertMessage(msg_error_duplicate, false, (delay = 30000)); +/** + * Async call to post Add, Edit or Delete categories + * + * @param url {string} : Url to call (add, edit, delete) + */ +function post_category_modal(url){ + let formData = new FormData(); + if(selectedVideos[catVideosListContainerId] && selectedVideos[catVideosListContainerId].length > 0){ + formData.append("videos", JSON.stringify(selectedVideos[catVideosListContainerId])); } - }; - - let get_type_icon = (is_video = true) => { - let videoContent_Text = gettext("Video content."); - let audioContent_Text = gettext("Audio content."); - if (is_video) { - return ''; + if(document.getElementById("cat-title")){ + formData.append("title", JSON.stringify(document.getElementById("cat-title").value)); } - return ''; - }; - - // Create category html element
  • - let getCategoryLi = (title, slug, id) => { - let btnEdit = document.createElement("button"); - btnEdit.setAttribute("title", gettext("Edit the category")); - btnEdit.setAttribute("data-bs-toggle", "modal"); - btnEdit.setAttribute("data-bs-target", "#manageCategoryModal"); - btnEdit.setAttribute("data-slug", slug); - btnEdit.setAttribute("data-title", title); - btnEdit.setAttribute("class", "btn btn-link edit_category"); - btnEdit.innerHTML = ``; - editHandler(btnEdit); // append edit click event - - let btnDelete = document.createElement("button"); - btnDelete.setAttribute("title", gettext("Delete the category")); - btnDelete.setAttribute("data-bs-toggle", "modal"); - btnDelete.setAttribute("data-bs-target", "#deleteCategoryModal"); - btnDelete.setAttribute("data-del", id); - btnDelete.setAttribute("data-slug", slug); - btnDelete.setAttribute("data-title", title); - btnDelete.setAttribute("class", "btn btn-link remove_category"); - btnDelete.innerHTML = ``; - deleteHandler(btnDelete); // append delete click event - - let li = document.createElement("li"); - li.setAttribute("class", "categories_list_item"); - li.innerHTML = ` -
    `; - let catTitleHtml = document.createElement("button"); - catTitleHtml.setAttribute("class", "btn btn-link cat_title"); - catTitleHtml.setAttribute("data-slug", slug); - catTitleHtml.innerText = title; - manageFilterVideos(catTitleHtml); // append filter click event - li.prepend(catTitleHtml); - li.querySelector(".category_actions").appendChild(btnEdit); - li.querySelector(".category_actions").appendChild(btnDelete); - return li; - }; - - // Create video html element card for Category Dialog - let createHtmlVideoCard = (v) => { - return ` -
    - - - -
    `; - }; - - // Create video video html element for dashboard (on filtering with category) - // video = object - let getVideoElement = (video) => { - let span_info = ` - - `; - let has_password = () => { - let span = ``; - let title = gettext("This content is password protected."); - if (video.has_password) { - span = ``; - } - return span; - }; - let has_chapter = () => { - let span = ``; - let title = gettext("This content is chaptered."); - if (video.has_chapter) { - span = ``; - } - return span; - }; - let is_draft = () => { - let span = ``; - let title = gettext("This content is in draft."); - if (video.is_draft) { - span = ` - `; + fetch(url, { + method: "POST", + headers: { + "X-Requested-With": "XMLHttpRequest", + "X-CSRFToken": csrftoken, + }, + body: formData, + cache: "no-store", + }) + .then((response) => response.text()) + .then((data) => { + data = JSON.parse(data); + bootstrap.Modal.getInstance(categoryModal).toggle(); + let message = data["message"]; + let videos = data["all_categories_videos"]; + showalert(message, "alert-success", "form-alert-div-bottom-right"); + if(videos !== undefined){ + all_categories_videos = JSON.parse(videos); } - return span; - }; - let is_video = () => { - let span = ``; - let title = gettext("Video content."); - if (video.is_video) { - span = ` - `; - } else { - title = gettext("Audio content."); - span = ``; - } - return span; - }; - let edit_text = gettext("Edit the video"); - let completion_text = gettext("Complete the video"); - let chapter_text = gettext("Chapter the video"); - let delete_text = gettext("Delete the video"); - let infinite_item = document.createElement("div"); - infinite_item.setAttribute("class", "infinite-item card-group"); - // infinite_item.setAttribute("style", "min-width: 12rem; min-height: 11rem;"); - infinite_item.setAttribute("data-slug", video.slug); - let card = document.createElement("div"); - card.setAttribute( - "class", - "card box-shadow pod-card--video video-card", // "card mb-4 box-shadow border-secondary video-card" - ); - card.innerHTML = ` -
    -
    - ${video.duration} - - ${has_password()} - ${is_draft()} - ${has_chapter()} - ${is_video()} - -
    -
    - - - `; - infinite_item.appendChild(card); - return infinite_item; - }; - - /** - * Create alert message - * @param {String} message message to be displayed - * @param {String|Boolean} type Message type - * @param {Number} delay [description] - * @return {[type]} [description] - */ - let showAlertMessage = (message, type = true, delay = 4000) => { - let success = gettext("Success!"); - let error = gettext("Error…"); - let title = type ? success : error; - let class_suffix = type ? "success" : "danger"; - let icon = - type === "success" - ? `` - : type === "error" - ? `` - : ``; - let alert_message = document.createElement("div"); - alert_message.setAttribute( - "class", - `category_alert alert alert-${class_suffix}`, - ); - alert_message.setAttribute("role", `alert`); - alert_message.innerHTML = `
    ${icon}${title}${message}`; - document.body.appendChild(alert_message); - window.setTimeout(() => alert_message.classList.add("show"), 1000); - window.setTimeout(() => { - alert_message.classList.add("hide"); - window.setTimeout(() => document.body.removeChild(alert_message), 1000); - }, delay); - }; - - /** - * Handler to edit category - * @param {[type]} c_e current category to edit - * @return {[type]} [description] - */ - let editHandler = (c_e) => { - c_e.addEventListener("click", (e) => { - e.preventDefault(); - showLoader(loader, true); - cat_edit_title = c_e.dataset.title.trim(); - cat_edit_slug = c_e.dataset.slug.trim(); - cat_edit_id = c_e.parentNode - .querySelector(".remove_category") - .dataset.del.trim(); - cat_input.value = cat_edit_title; - modal_title.innerText = c_e.getAttribute("title").trim(); - window.setTimeout(function () { - cat_input.focus(); - }, 500); // focus in input (category title) - - // add videos of the current category into the dialog - saveCatBtn.setAttribute("data-action", "edit"); - saveCatBtn.innerText = msg_save_cat; - let jsonData = getSavedData(cat_edit_id); - CURR_CATEGORY = jsonData; - DOMCurrentEditCat = c_e.parentNode.parentNode; - if (Object.keys(jsonData).length) { - paginate(jsonData.videos); - showLoader(loader, false); - } else { - jsonData = fetchCategoryData(cat_edit_slug); - jsonData - .then((data) => { - paginate(data.videos); - // save data - saveCategoryData(data); - CURR_CATEGORY = data; - showLoader(loader, false); - }) - .catch((e) => { - showLoader(loader, false); - showAlertMessage(msg_error, false, (delay = 30000)); - }); - } - }); - }; - - /** - * get modal video card - * @param {[type]} v [description] - * @param {Boolean} selected [description] - * @return {[type]} [description] - */ - let getModalVideoCard = (v, selected = true) => { - let videoCard = createHtmlVideoCard(v); - let v_wrapper = document.createElement("Div"); - v_wrapper.setAttribute("data-slug", v.slug); - let selectClass = selected ? "selected" : ""; - v_wrapper.setAttribute( - "class", - "infinite-item col-12 col-md-6 col-lg-3 mb-2 card-group " + selectClass, - ); - v_wrapper.innerHTML = videoCard; - return v_wrapper; - }; - // Append video card in category modal - let appendVideoCard = (v) => { - let modalListVideo = document.querySelector( - "#manageCategoryModal .category_modal_videos_list", - ); - modalListVideo.insertBefore(v, modalListVideo.querySelector(".paginator")); - }; - - // Add onclick event to edit a category - let cats_edit = document.querySelectorAll( - ".categories_list_item .edit_category", - ); - cats_edit.forEach((c_e) => { - editHandler(c_e); - }); - - // Handler to delete category, c_d=current category to delete - // Temporarily save the category to delete - let deleteHandler = (c_d) => { - c_d.addEventListener("click", (e) => { - e.preventDefault(); - // Show confirm modal => manage by boostrap - CAT_TO_DELETE.html = c_d.parentNode.parentNode; - CAT_TO_DELETE.id = c_d.dataset.del; - CAT_TO_DELETE.slug = c_d.dataset.slug; - document.querySelector( - "#deleteCategoryModal .modal-body .category_title", - ).textContent = c_d.dataset.title; + refreshCategoriesLinks(); + }) + .catch(() => { + showalert(gettext("An Error occurred while processing."), "alert-danger", "form-alert-div-bottom-right"); }); - }; - - // Add onclick event to delete a category - let cats_del = document.querySelectorAll( - ".categories_list_item .remove_category", - ); - cats_del.forEach((c_d) => { - deleteHandler(c_d); - }); - - // Add onclick event to delete a category - let del_cat = document.querySelector("#confirm_remove_category_btn"); - del_cat.addEventListener("click", () => { - showLoader(loader, true); - if (CAT_TO_DELETE.slug && CAT_TO_DELETE.id && CAT_TO_DELETE.html) { - // Delete category - let cat = findCategory( - (slug = CAT_TO_DELETE.slug), - (id = CAT_TO_DELETE.id), - ); - if (Object.keys(cat).length) { - fetch(`${BASE_URL}delete/${cat.id}/`, { - method: "POST", - headers: HEADERS, - }).then((response) => { - response.json().then((data) => { - showAlertMessage(msg_deleted); - deleteFromSavedData(cat.slug); // delete from local save - - // TODO : Ici il faudrait masquer la recherche si c'est la dernière cat supprimée, et l'afficher sinon. - - // Remove eventual alert message - const cat_modal_alert = document.querySelector( - "#manageCategoryModal .modal-body .alert-warning", - ); - if ( - typeof cat_modal_alert != "undefined" && - cat_modal_alert != null - ) { - cat_modal_alert.remove(); - } - data.videos.forEach((v) => { - // append all the videos into chunk videos unselected - VIDEOS_LIST_CHUNK.videos.unselected = [ - ...VIDEOS_LIST_CHUNK.videos.unselected, - getModalVideoCard(v, false), - ]; - }); - document - .querySelector(".categories_list") - .removeChild(CAT_TO_DELETE.html); - let filtered_container = getVideosFilteredContainer(); - if ( - filtered_container && - CAT_TO_DELETE.id == CURR_FILTER.id && - CAT_TO_DELETE.slug === CURR_FILTER.slug - ) { - filtered_container.parentNode.removeChild(filtered_container); - let my_videos_container = document.querySelector( - ".infinite-container.hidden", - ); - if (my_videos_container) - my_videos_container.classList.remove("hidden"); - manageNumberVideoFoundText(CATEGORIES_DATA[0]); - CURR_FILTER.slug = null; - CURR_FILTER.id = null; - document - .querySelectorAll(".categories_list .categories_list_item") - .forEach((c) => c.classList.remove("active")); - } - delete CAT_TO_DELETE.html; - delete CAT_TO_DELETE.id; - delete CAT_TO_DELETE.slug; - showLoader(loader, false); - // close modal - document - .querySelector("#deleteCategoryModal .modal-footer .close_modal") - .click(); - }); - }); - } else { - //TODO display msg error cat - showLoader(loader, false); - } - } else { - // display msg error like 'no category to delete' - showLoader(loader, false); - } - }); +} - // Add onclick event to save category (create or edit) data - saveCatBtn.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - - showLoader(loader, true); - - //let videos = Array.from(document.querySelectorAll(".category_modal_videos_list .selected")).map(v_el => v_el.dataset.slug.trim()); - const videos = VIDEOS_LIST_CHUNK.videos.selected.map( - (html_v) => html_v.dataset.slug, - ); - let postData = { - title: cat_input.value.trim(), - videos: videos, - }; - if (cat_input.value.trim() === "") { - showAlertMessage(msg_title_empty, false, (delay = 30000)); - showLoader(loader, false); - return; +/** + * Manage category videos pagination (category modal's videos list) + * + * @param el {HTMLElement} : Previous or next clicked button + */ +function manageCategoryVideosPagination(el){ + let currentPage = parseInt(document.getElementById("pages_infos").dataset.currentPage); + if(el.dataset.pageaction === "previous"){ + get_category_modal(currentUrl, currentPage - 1); + }else if(el.dataset.pageaction === "next"){ + get_category_modal(currentUrl, currentPage + 1); } +} - if (Object.keys(CURR_CATEGORY).length > 0 && DOMCurrentEditCat) { - // Editing mode - // Update new data, server side - postCategoryData(`${BASE_URL}edit/${CURR_CATEGORY.slug}/`, postData) - .then((data) => { - // Update new data, client side - updateFilteredVideosContainer(data); // update filered videos in filtered container - deleteFromSavedData(CURR_CATEGORY.slug); - saveCategoryData(data); - DOMCurrentEditCat.querySelector(".cat_title").textContent = - data.title; - DOMCurrentEditCat.querySelector(".cat_title").setAttribute( - "title", - data.title, - ); - DOMCurrentEditCat.querySelectorAll("span").forEach((sp) => { - sp.setAttribute("data-slug", data.slug); - sp.setAttribute("data-title", data.title); - }); - DOMCurrentEditCat = null; - CURR_CATEGORY = {}; - // close modal - document.querySelector("#manageCategoryModal #cancelDialog").click(); - refreshDialog(); - showAlertMessage(msg_saved); - showLoader(loader, false); // hide loader - }) - .catch(() => { - showLoader(loader, false); - showAlertMessage(msg_error, false, (delay = 30000)); - }); - } else { - // Adding mode - postCategoryData(`${BASE_URL}add/`, postData) - .then((data) => { - let li = getCategoryLi( - data.category.title, - data.category.slug, - data.category.id, - ); - document.querySelector(".categories_list").appendChild(li); - let msg_create = gettext("Category created successfully"); - showAlertMessage(msg_create); - saveCategoryData(data.category); // saving cat localy to prevent more request to the server - showLoader(loader, false); // hide loader - }) - .catch(() => { - //alert(err); - showAlertMessage(msg_error_duplicate, false, (delay = 30000)); +/** + * Dynamically add event listener on confirm button (Add, Edit or Delete) of category modal + */ +function manageModalConfirmBtn(){ + let btn = document.getElementById("confirm-category-btn"); + if(btn !== undefined && btn.dataset.action !== undefined){ + btn.addEventListener("click", () => { + let action = btn.dataset.action; + let slug = btn.dataset.slug ? btn.dataset.slug : null; + let url_post = getCategoriesUrl(action, slug) + post_category_modal(url_post); }); - document.querySelector("#manageCategoryModal #cancelDialog").click(); - refreshDialog(); } - }); +} - // Add onclick event to add a new category - let add_cat = document.querySelector("#add_category_btn"); - add_cat.addEventListener("click", () => { - paginate([]); - modal_title.innerText = gettext("Add new category"); - cat_input.value = ""; - // Change the Save button text to 'create category' - saveCatBtn.textContent = gettext("Create category"); - saveCatBtn.setAttribute("data-action", "create"); - CURR_CATEGORY = {}; - window.setTimeout(function () { - cat_input.focus(); - }, 500); - }); +/** + * Refresh filter aside's category links after treatment + */ +function refreshCategoriesLinks(){ + fetch(CATEGORIES_LIST_URL, { + method: "GET", + headers: { + "X-CSRFToken": "{{ csrf_token }}", + "X-Requested-With": "XMLHttpRequest", + }, + cache: "no-store", + }) + .then((response) => response.text()) + .then((data) => { + // Parse data into html and replace categories list + let parser = new DOMParser(); + let html = parser.parseFromString(data, "text/html").body; + categoriesListContainer.innerHTML = html.innerHTML; + manageCategoriesLinks(); + }) + .catch(() => { + showalert(gettext("An Error occurred while processing."), "alert-danger", "form-alert-div-bottom-right"); + }); +} - // Add click event on category in filter bar to filter videos in my_videos vue - let cats = document.querySelectorAll(".categories_list_item .cat_title"); - cats.forEach((c) => { - manageFilterVideos(c); - }); -})(JSON.parse(JSON.stringify(CATEGORIES_DATA))); +// Add event listeners on categories list buttons for the first time +manageCategoriesLinks(); diff --git a/pod/video/static/js/video_select.js b/pod/video/static/js/video_select.js index 396ea3be88..af7a344364 100644 --- a/pod/video/static/js/video_select.js +++ b/pod/video/static/js/video_select.js @@ -10,7 +10,7 @@ /* exported resetDashboardElements getHTMLBadgesSelectedTitles toggleSelectedVideo setSelectedVideos */ -var selectedVideos = []; +var selectedVideos = {}; var applyMultipleActionsBtn = document.getElementById("applyBulkUpdateBtn"); var resetDashboardElementsBtn = document.getElementById( "reset-dashboard-elements-btn", @@ -20,18 +20,20 @@ var countSelectedVideosBadge = document.getElementById( ); /** - * Get list of selected videos's titles based on class selected + * Get list of selected videos's titles based on selected videos + * + * @param {string} container : Identifier of container = selectedVideos's key * @returns {*[video_title]} */ -function getListSelectedVideosTitles() { +function getListSelectedVideosTitles(container) { let selectedTitles = []; - if (selectedVideos.length > 0) { - Array.from(selectedVideos).forEach((v) => { + if (selectedVideos[container].length > 0) { + Array.from(selectedVideos[container]).forEach((v) => { let item = document.querySelector( - ".infinite-item.selected[data-slug='" + v + "']", + "#" + container + " .infinite-item[data-slug='" + v + "']" ); selectedTitles.push( - item.querySelector(".dashboard-video-title").getAttribute("title"), + item.querySelector(".dashboard-video-title").dataset.videoTitle, ); }); } @@ -40,35 +42,43 @@ function getListSelectedVideosTitles() { /** * Set shared/global variable selectedVideos with selected videos based on class selected + * + * @param {string} container : Identifier of container = selectedVideos's key */ -function setListSelectedVideos() { - selectedVideos = []; - document.querySelectorAll(".infinite-item.selected").forEach((elt) => { - selectedVideos.push(elt.dataset.slug); +function setListSelectedVideos(container) { + if(container === videosListContainerId){ + selectedVideos[container] = []; + } + let selector = "#" + container + " .card-select-input:checked"; + document.querySelectorAll(selector).forEach((elt) => { + if(selectedVideos[container].indexOf(elt.dataset.slug) === -1){ + selectedVideos[container].push(elt.dataset.slug); + } }); } /** * Set directly selected videos on interface to improve user experience + * + * @param {string} container : Identifier of container = selectedVideos's key */ -function setSelectedVideos() { - Array.from(selectedVideos).forEach((elt) => { - let domElt = document.querySelector( - '#videos_list .infinite-item[data-slug="' + elt + '"]', - ); - if (domElt && !domElt.classList.contains("selected")) { - if (!domElt.classList.contains("selected")) { - domElt.classList.add("selected"); - } +function setSelectedVideos(container) { + Array.from(selectedVideos[container]).forEach((elt) => { + let selector = '#' + container + ' .card-select-input[data-slug="' + elt + '"]'; + let domElt = document.querySelector(selector); + if (domElt && !domElt.checked) { + domElt.checked = true; } }); } /** * Replace count of selected videos (count label in "Apply" bulk update's badge) + * + * @param {string} container : Identifier of container = selectedVideos's key */ -function replaceSelectedCountVideos() { - let newCount = selectedVideos.length; +function replaceSelectedCountVideos(container) { + let newCount = selectedVideos[container].length; let videoCountStr = ngettext("%(count)s video", "%(count)s videos", newCount); let videoCountTit = ngettext( "%(count)s video selected", @@ -82,38 +92,44 @@ function replaceSelectedCountVideos() { applyMultipleActionsBtn, newCount > 0 && dashboardAction.length !== 0, ); - manageDisableBtn(resetDashboardElementsBtn, newCount > 0); + manageDisableBtn( + resetDashboardElementsBtn, + newCount > 0, + ); } /** * Toggle class selected for video cards or list-item, avoid select a video when click on links - * @param item + * + * @param {HTMLElement} item : HTMLElement to be toggled + * @param {string} container : Identifier of container = selectedVideos's key */ -function toggleSelectedVideo(item) { - // Prevent item to select if link is clicked - if ( - event.target.tagName === "A" || - event.target.tagName === "BUTTON" || - event.target.classList.contains("card-footer-link-i") || - event.target.classList.contains("more-actions-row-i") || - (event.keyCode && event.keyCode !== 13) - ) { - return; +function toggleSelectedVideo(item, container) { + if(item.checked){ + if(!selectedVideos[container].includes(item.dataset.slug)){ + selectedVideos[container].push(item.dataset.slug); + } + }else{ + if (selectedVideos[container].includes(item.dataset.slug)){ + selectedVideos[container].splice(selectedVideos[container].indexOf(item.dataset.slug),1); + } + } + if(container === videosListContainerId) { + replaceSelectedCountVideos(container); } - item.classList.toggle("selected"); - setListSelectedVideos(); - replaceSelectedCountVideos(); } /** * Clear videos selection : deselect all videos, reset badge count + * + * @param {string} container : Identifier of container = selectedVideos's key */ -function clearSelectedVideo() { - selectedVideos = []; - document.querySelectorAll(".infinite-item.selected").forEach((elt) => { - elt.classList.remove("selected"); +function clearSelectedVideo(container) { + selectedVideos[container] = []; + document.querySelectorAll(".card-select-input").forEach((elt) => { + elt.checked = false; }); - replaceSelectedCountVideos(); + replaceSelectedCountVideos(container); } /** @@ -122,17 +138,20 @@ function clearSelectedVideo() { * @see resetDashboardElementsBtn **/ function resetDashboardElements() { - clearSelectedVideo(); + clearSelectedVideo(videosListContainerId); dashboardActionReset(); window.scrollTo(0, 0); } /** - * Get list of selected videos slugs (HTML li formated) for modal confirm display + * Get list of selected videos slugs (HTML li formated) for modal confirm display. + * + * @param {HTMLElement} container - The container element that holds the selected videos. + * @returns {string} - HTML string of badges for the selected video titles. */ -function getHTMLBadgesSelectedTitles() { +function getHTMLBadgesSelectedTitles(container) { let str = ""; - Array.from(getListSelectedVideosTitles()).forEach((title) => { + Array.from(getListSelectedVideosTitles(container)).forEach((title) => { str += "" + title + @@ -140,3 +159,17 @@ function getHTMLBadgesSelectedTitles() { }); return str; } + +/** + * Select all videos (visible infinite-item) on given container + * + * @param {string} container : Identifier of the infinite-items's container + */ +function selectAllVideos(container){ + let selector = "#" + container + " .card-select-input"; + document.querySelectorAll(selector).forEach((elt) => { + elt.checked = true; + }); + setListSelectedVideos(container); + replaceSelectedCountVideos(container); +} diff --git a/pod/video/templates/videos/add_video.html b/pod/video/templates/videos/add_video.html index 49361d5fdd..ec05b7a930 100644 --- a/pod/video/templates/videos/add_video.html +++ b/pod/video/templates/videos/add_video.html @@ -65,7 +65,7 @@

     {% trans "Legal noti

    diff --git a/pod/video/templates/videos/card_select.html b/pod/video/templates/videos/card_select.html index d6d16a54d9..35cc690152 100644 --- a/pod/video/templates/videos/card_select.html +++ b/pod/video/templates/videos/card_select.html @@ -1,61 +1,86 @@ {% load i18n %} +{% load video_tags %} + {% spaceless %} - -
    - - - -
    +
    + {{video.duration_in_time}} - - - {% if video.password or video.is_restricted %} - - - - {% endif %} - - {% if video.is_draft %} - - - - {% endif %} - - {% if video.chapter_set.all %} - - - - {% endif %} - - {% if video.is_video %} - - - - {% else %} - - - - {% endif %} - - + + {% if not category_modal %} + + {% if video.password %} + + + + {% endif %} + {% if video.is_restricted %} + + + + {% endif %} + {% if video.is_draft %} + + + + {% endif %} + {% if video.chapter_set.all %} + + + + {% endif %} + {% if video.is_video %} + + + + {% else %} + + + + {% endif %} + + {% endif %} +
    +
    - {{video.get_thumbnail_card|safe}} -
    -
    + + {{video.get_thumbnail_card|safe}} + {% if request.user.is_authenticated %} + {% get_percent_marker_for_user video request.user as percent_view %} + {% if percent_view != 0 %} +
    +
    +
    + {% endif %} + {% endif %} +
    + +
    + {% if request.user.is_authenticated and not category_modal %}
    {% include "videos/link_video.html" %}
    {% endif %} - - {{video.title|capfirst|truncatechars:25}} + + 25 %}title="{{video.title|capfirst}}"{% endif %}>{{video.title|capfirst|truncatechars:25}}
    diff --git a/pod/video/templates/videos/category_modal.html b/pod/video/templates/videos/category_modal.html index 7fdd99e94c..8a33465406 100644 --- a/pod/video/templates/videos/category_modal.html +++ b/pod/video/templates/videos/category_modal.html @@ -1,72 +1,46 @@ {% load i18n %} {% load video_filters %} - - - diff --git a/pod/video/templates/videos/category_modal_card.html b/pod/video/templates/videos/category_modal_card.html deleted file mode 100644 index d79477276e..0000000000 --- a/pod/video/templates/videos/category_modal_card.html +++ /dev/null @@ -1,31 +0,0 @@ -{% load i18n %} -{% spaceless %} - -{% endspaceless %} diff --git a/pod/video/templates/videos/category_modal_video_list.html b/pod/video/templates/videos/category_modal_video_list.html new file mode 100644 index 0000000000..93e03e1328 --- /dev/null +++ b/pod/video/templates/videos/category_modal_video_list.html @@ -0,0 +1,26 @@ +{% load i18n %} +{% load static %} + +{% spaceless %} +
    + {% for video in videos %} +
    + {% include "videos/card_select.html" with category_modal="True" %} +
    + {% endfor %} + + {% if videos.has_other_pages %} + + {% endif %} +
    +{% endspaceless %} diff --git a/pod/video/templates/videos/change_video_owner.html b/pod/video/templates/videos/change_video_owner.html index 2eabbb17f8..6105689d1c 100644 --- a/pod/video/templates/videos/change_video_owner.html +++ b/pod/video/templates/videos/change_video_owner.html @@ -26,7 +26,7 @@
    -
    +
    {% trans "Select video(s) to edit" %} - * + *
    -
    @@ -87,7 +87,7 @@
    -
    diff --git a/pod/video/templates/videos/dashboard.html b/pod/video/templates/videos/dashboard.html index 3a6a3381b3..36797afad6 100644 --- a/pod/video/templates/videos/dashboard.html +++ b/pod/video/templates/videos/dashboard.html @@ -13,20 +13,19 @@ {% block page_content %} {% if use_category %} - {% include "videos/category_modal.html" %} + {% endif %} -
    +

    {% trans 'Multiple actions' %}

    -

    +

    {% trans "To edit several videos at the same time, you can select the ones you want by clicking on them then select an action to perform using the drop-down menu below, finally apply the modification. You can also refine your video search using the filters in the right menu." %}

    - + - {# #} {% if request.user.is_superuser %} @@ -115,7 +114,8 @@

    {% trans 'Multiple actions' %}

    - + +
    @@ -155,6 +155,7 @@

    {% blocktrans count counter=count_videos %}{{ const urlVideos = "{% url 'video:dashboard' %}"; const urlUpdateVideos = "{% url 'video:bulk_update' %}"; const videosListLoader = document.getElementById("videosListLoader"); + const videosListContainerId = "videos_list"; var displayMode = "{{ display_mode }}"; var csrftoken = "{{ csrf_token }}"; var page = 1; @@ -174,8 +175,13 @@

    {% blocktrans count counter=count_videos %}{{ {% if use_category %} - + {% else %} {% endif %} diff --git a/pod/video/templates/videos/filter_aside_categories_list.html b/pod/video/templates/videos/filter_aside_categories_list.html new file mode 100644 index 0000000000..028755f6af --- /dev/null +++ b/pod/video/templates/videos/filter_aside_categories_list.html @@ -0,0 +1,15 @@ +{% load i18n %} + +{% for category in categories %} +
  • + +
    + + +
    +
  • +{% endfor %} diff --git a/pod/video/templates/videos/filter_aside_category.html b/pod/video/templates/videos/filter_aside_category.html index fefeffaf94..caeb7e52a3 100644 --- a/pod/video/templates/videos/filter_aside_category.html +++ b/pod/video/templates/videos/filter_aside_category.html @@ -7,32 +7,19 @@  {% trans "Categories" %}
    -
    - {% str_to_dict categories as categories %} -
    - - +
    + +
    diff --git a/pod/video/templates/videos/link_video.html b/pod/video/templates/videos/link_video.html index 2fedc15f0a..bed4e0bb88 100644 --- a/pod/video/templates/videos/link_video.html +++ b/pod/video/templates/videos/link_video.html @@ -3,11 +3,6 @@ {% load playlist_buttons %} {% spaceless %} -{% if request.path == '/video/dashboard/' %} - - - -{% endif %} {% if playlist %} {% user_can_edit_or_remove playlist as can_edit_or_remove %} {% if not in_favorites_playlist and can_edit_or_remove%} diff --git a/pod/video/templates/videos/video_list_grid_selectable.html b/pod/video/templates/videos/video_list_grid_selectable.html index ea5ea01159..c3ea11b8b0 100644 --- a/pod/video/templates/videos/video_list_grid_selectable.html +++ b/pod/video/templates/videos/video_list_grid_selectable.html @@ -4,7 +4,7 @@
    {% for video in videos %} -
    +
    {% include "videos/card_select.html" %}
    {% empty %} @@ -26,7 +26,7 @@ {% trans "Loading…" %}
    {% endspaceless %} - + {% if USE_PLAYLIST and USE_FAVORITES %} diff --git a/pod/video/templates/videos/video_list_table_selectable.html b/pod/video/templates/videos/video_list_table_selectable.html index d9750bb193..aab1492768 100644 --- a/pod/video/templates/videos/video_list_table_selectable.html +++ b/pod/video/templates/videos/video_list_table_selectable.html @@ -4,7 +4,7 @@
      {% for video in videos %} -
    • +
    • {% include "videos/video_row_select.html" %}
    • {% empty %} @@ -27,4 +27,4 @@ {% trans "Loading…" %}
    {% endspaceless %} - + diff --git a/pod/video/templates/videos/video_row_select.html b/pod/video/templates/videos/video_row_select.html index 789752c904..9554d8b98b 100644 --- a/pod/video/templates/videos/video_row_select.html +++ b/pod/video/templates/videos/video_row_select.html @@ -8,17 +8,30 @@ {% load playlist_buttons %} {% can_see_playlist_video video playlist as can_see_video %} {% endif %} -
    + + + +
    - - {{ video.title|capfirst|truncatechars:20 }} + + 20 %}title="{{video.title|capfirst}}"{% endif %}>{{video.title|capfirst|truncatechars:20}} {% trans 'Duration' %} @@ -26,7 +39,7 @@ {% trans 'Date added' %} -  {{ video.date_added }} +  {{ video.date_added|date }}
    @@ -63,7 +76,7 @@ {% if video_infos.chaptered.status %} class="bi bi-card-checklist text-success" {% else %} - class="bi bi-card-list text-muted" + class="bi bi-card-list text-danger" {% endif %} aria-hidden="true"> @@ -78,6 +91,7 @@ {% endif %}
    +
    {% endif %} + {% if field.id_for_label == "id_password" %} +
    + {% endif %}
    {% endif %} diff --git a/pod/video/tests/test_views.py b/pod/video/tests/test_views.py index d9c398f445..bc36051e86 100644 --- a/pod/video/tests/test_views.py +++ b/pod/video/tests/test_views.py @@ -852,27 +852,28 @@ def test_video_edit_post_request(self) -> None: "main_lang": "fr", "cursus": "0", "type": 1, + "visibility": "public", }, follow=True, ) self.assertEqual(response.status_code, HTTPStatus.OK) - # print(response.context["form"].errors) self.assertTrue(b"The changes have been saved." in response.content) v = Video.objects.get(title="VideoTest1") self.assertEqual(v.description, "

    bl

    ") - videofile = SimpleUploadedFile( + video_file = SimpleUploadedFile( "file.mp4", b"file_content", content_type="video/mp4" ) url = reverse("video:video_edit", kwargs={"slug": v.slug}) response = self.client.post( url, { - "video": videofile, + "video": video_file, "title": "VideoTest1", "main_lang": "fr", "cursus": "0", "type": 1, + "visibility": "public", }, follow=True, ) @@ -882,19 +883,20 @@ def test_video_edit_post_request(self) -> None: p = re.compile(r"^videos/([\d\w]+)/file([_\d\w]*).mp4$") self.assertRegex(v.video.name, p) # new one - videofile = SimpleUploadedFile( + video_file = SimpleUploadedFile( "file.mp4", b"file_content", content_type="video/mp4" ) url = reverse("video:video_edit", kwargs={}) self.client.post( url, { - "video": videofile, + "video": video_file, "title": "VideoTest2", "description": "

    coucou

    \r\n", "main_lang": "fr", "cursus": "0", "type": 1, + "visibility": "public", }, ) self.assertEqual(response.status_code, HTTPStatus.OK) @@ -916,6 +918,7 @@ def test_video_edit_post_request(self) -> None: "cursus": "0", "type": 1, "additional_owners": [self.user.pk], + "visibility": "public", }, follow=True, ) @@ -924,19 +927,20 @@ def test_video_edit_post_request(self) -> None: v = Video.objects.get(title="VideoTest3") self.assertEqual(v.description, "

    bl

    ") - videofile = SimpleUploadedFile( + video_file = SimpleUploadedFile( "file.mp4", b"file_content", content_type="video/mp4" ) url = reverse("video:video_edit", kwargs={"slug": v.slug}) response = self.client.post( url, { - "video": videofile, + "video": video_file, "title": "VideoTest3", "main_lang": "fr", "cursus": "0", "type": 1, "additional_owners": [self.user.pk], + "visibility": "public", }, follow=True, ) @@ -945,7 +949,7 @@ def test_video_edit_post_request(self) -> None: print(" ---> test_video_edit_post_request of VideoEditTestView: OK!") -class video_deleteTestView(TestCase): +class VideoDeleteTestView(TestCase): fixtures = [ "initial_data.json", ] From 5bd16bee4d879a4fca525a6df71b0034da73c01f Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 17 Jul 2024 09:09:34 +0000 Subject: [PATCH 21/24] Fixup. Format code with Prettier --- pod/video/static/js/video_edit.js | 34 ++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/pod/video/static/js/video_edit.js b/pod/video/static/js/video_edit.js index 82345b5278..9d1d343c63 100644 --- a/pod/video/static/js/video_edit.js +++ b/pod/video/static/js/video_edit.js @@ -10,7 +10,9 @@ document.addEventListener( function () { const visibilitySelect = document.getElementById("id_visibility"); const passwordField = document.getElementById("id_password").parentElement; - visibilitySelect.addEventListener('change', () => toggleFields(visibilitySelect, passwordField)); + visibilitySelect.addEventListener("change", () => + toggleFields(visibilitySelect, passwordField), + ); toggleFields(visibilitySelect, passwordField); // Display type description as field help when changed const target = "id_type"; @@ -25,7 +27,6 @@ document.addEventListener( false, ); - /** * Apply the select2 style to the select elements. */ @@ -37,8 +38,6 @@ function applySelect2Style() { }); } - - /** * Toggle the password field visibility based on the visibility select value. * @@ -46,7 +45,9 @@ function applySelect2Style() { * @param passwordField {HTMLElement} - The password field container. */ function toggleFields(visibilitySelect, passwordField) { - const idRestrictToGroupsField = document.getElementById("id_restrict_access_to_groups"); + const idRestrictToGroupsField = document.getElementById( + "id_restrict_access_to_groups", + ); const idIsRestrictedField = document.getElementById("id_is_restricted"); if (visibilitySelect.value === "restricted") { passwordField.closest(".field-password").classList.add("show"); @@ -54,15 +55,21 @@ function toggleFields(visibilitySelect, passwordField) { // For first call, apply select2 style to the select elements. if (idIsRestrictedField.checked) { applySelect2Style(); - idRestrictToGroupsField.closest(".field-restrict_access_to_groups").classList.add("show"); + idRestrictToGroupsField + .closest(".field-restrict_access_to_groups") + .classList.add("show"); } // --- idIsRestrictedField.addEventListener("change", () => { if (idIsRestrictedField.checked) { applySelect2Style(); - idRestrictToGroupsField.closest(".field-restrict_access_to_groups").classList.add("show"); + idRestrictToGroupsField + .closest(".field-restrict_access_to_groups") + .classList.add("show"); } else { - idRestrictToGroupsField.closest(".field-restrict_access_to_groups").classList.remove("show"); + idRestrictToGroupsField + .closest(".field-restrict_access_to_groups") + .classList.remove("show"); } }); idIsRestrictedField.closest(".field-is_restricted").classList.add("show"); @@ -80,16 +87,19 @@ function toggleFields(visibilitySelect, passwordField) { }); }); if (idRestrictToGroupsField) { - idRestrictToGroupsField.closest(".field-restrict_access_to_groups").classList.remove("show"); + idRestrictToGroupsField + .closest(".field-restrict_access_to_groups") + .classList.remove("show"); } if (idIsRestrictedField) { - idIsRestrictedField.closest(".field-is_restricted").classList.remove("show"); + idIsRestrictedField + .closest(".field-is_restricted") + .classList.remove("show"); } } } } - /** * Display the description of the selected option in a select box. * @@ -221,7 +231,7 @@ if (document.getElementById("video_form")) { const notificationMessage = document.querySelector( "#notification-toast>.toast-body>p", ); -if(notificationMessage) { +if (notificationMessage) { notificationMessage.textContent = gettext( "Get notified when the video encoding is finished.", ); From 2b1c7e498caf9a35da28489f0bcb466b9034c3f2 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 17 Jul 2024 09:09:45 +0000 Subject: [PATCH 22/24] Fixup. Format code with Black --- pod/video/forms.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/pod/video/forms.py b/pod/video/forms.py index 00e8b4cb6a..87ff7ccdb2 100644 --- a/pod/video/forms.py +++ b/pod/video/forms.py @@ -277,9 +277,7 @@ ( "{0}".format(_("Visibility")), [ - _( - "In “Public” mode, the content is visible to everyone." - ), + _("In “Public” mode, the content is visible to everyone."), _( "In “Draft / Private” mode, the content shows nowhere and nobody " "else but you can see it." @@ -616,7 +614,6 @@ class VideoForm(forms.ModelForm): videoattrs = { "class": "form-control-file", "accept": "audio/*, video/*, .%s" - % ", .".join(map(str, VIDEO_ALLOWED_EXTENSIONS)), } is_admin = False @@ -747,16 +744,16 @@ def change_encoded_path(self, video, new_dir, old_dir) -> None: def save_visibility(self): """Save video access fields depends on the visibility field value.""" - visibility = self.cleaned_data.get('visibility') - if visibility == 'public': + visibility = self.cleaned_data.get("visibility") + if visibility == "public": self.instance.is_draft = False self.instance.is_restricted = False self.instance.password = None - elif visibility == 'draft': + elif visibility == "draft": self.instance.is_draft = True self.instance.is_restricted = False self.instance.password = None - elif visibility == 'restricted': + elif visibility == "restricted": self.instance.is_draft = False def save(self, commit=True, *args, **kwargs): @@ -824,15 +821,14 @@ def clean_date_delete(self): def check_visibility(self, cleaned_data) -> None: """Check the visibility field.""" - visibility = cleaned_data.get('visibility', '') - is_restricted = cleaned_data.get('is_restricted', False) - password = cleaned_data.get('password', '') - if ( - visibility == 'restricted' - and is_restricted is False and password is None - ): + visibility = cleaned_data.get("visibility", "") + is_restricted = cleaned_data.get("is_restricted", False) + password = cleaned_data.get("password", "") + if visibility == "restricted" and is_restricted is False and password is None: raise ValidationError( - _("If you select restricted visibility for your video, you must check the \"restricted access\" box or specify a password.") + _( + 'If you select restricted visibility for your video, you must check the "restricted access" box or specify a password.' + ) ) def clean(self) -> None: @@ -967,11 +963,11 @@ def __init_instance__(self): if self.instance: if self.instance.is_draft: self.initial["visibility"] = "draft" - elif (self.instance.is_restricted or self.instance.password): + elif self.instance.is_restricted or self.instance.password: self.initial["visibility"] = "restricted" else: self.initial["visibility"] = "public" - self.fields['is_draft'].widget = forms.HiddenInput() + self.fields["is_draft"].widget = forms.HiddenInput() self.order_fields(["visibility", "password"] + list(self.fields.keys())) def custom_video_form(self) -> None: From 54b4b33faa0dc47a8e163f98c2ce65cd0f57f538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Coze?= <96086580+SebastienCozeDev@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:11:04 +0200 Subject: [PATCH 23/24] [DONE] Quiz improvements - 3.8.0 & Modify roles for playlists (#1173) * :bug: Fix gitguardian [test] * :card_file_box: Add first version of question migrations * :card_file_box: Fix migrations * :card_file_box: Fix migrations * :busts_in_silhouette: Fix review requests * :goal_net: Add try catch * fix flake8 * Add templatetag loading for quiz in card_select.html --------- Co-authored-by: Aymeric Jakobowski Co-authored-by: Ptitloup --- pod/bbb/views.py | 2 - pod/cut/templates/video_cut.html | 16 +- pod/live/templates/live/event_edit.html | 2 +- pod/locale/fr/LC_MESSAGES/django.mo | Bin 225846 -> 227848 bytes pod/locale/fr/LC_MESSAGES/django.po | 129 ++++++++--- pod/locale/fr/LC_MESSAGES/djangojs.mo | Bin 20848 -> 20753 bytes pod/locale/fr/LC_MESSAGES/djangojs.po | 16 +- pod/locale/nl/LC_MESSAGES/django.po | 86 +++++--- pod/locale/nl/LC_MESSAGES/djangojs.po | 10 +- pod/playlist/forms.py | 5 +- pod/playlist/templatetags/playlist_buttons.py | 2 +- pod/playlist/utils.py | 4 +- pod/playlist/views.py | 4 +- pod/quiz/admin.py | 14 -- pod/quiz/apps.py | 120 +++++++++++ pod/quiz/forms.py | 31 +-- pod/quiz/models.py | 113 ++++++---- pod/quiz/static/quiz/js/create-quiz.js | 47 ---- pod/quiz/static/quiz/js/video-quiz-submit.js | 49 +++-- pod/quiz/templates/quiz/create_edit_quiz.html | 2 + .../templates/quiz/question_help_aside.html | 45 ++++ pod/quiz/templates/quiz/video_quiz.html | 13 +- pod/quiz/templatetags/video_quiz.py | 7 +- pod/quiz/tests/test_models.py | 69 ------ pod/quiz/tests/test_utils.py | 11 +- pod/quiz/tests/test_views.py | 4 - pod/quiz/utils.py | 36 +++- pod/quiz/views.py | 70 +----- pod/video/static/js/comment-script.js | 1 - pod/video/templates/videos/card.html | 204 +++++++++--------- pod/video/templates/videos/card_select.html | 16 +- 31 files changed, 621 insertions(+), 507 deletions(-) create mode 100644 pod/quiz/templates/quiz/question_help_aside.html diff --git a/pod/bbb/views.py b/pod/bbb/views.py index 2ba3685eb8..0b6df81de4 100644 --- a/pod/bbb/views.py +++ b/pod/bbb/views.py @@ -35,7 +35,6 @@ def list_meeting(request): attendee__user_id=request.user.id, recording_available=True ) meetings_list = meetings_list.order_by("-session_date") - # print(str(meetings_list.query)) page = request.GET.get("page", 1) @@ -145,7 +144,6 @@ def live_list_meeting(request): last_date_in_progress__gte=dateSince10Min, ) meetings_list = meetings_list.order_by("-session_date") - # print(str(meetings_list.query)) meetings_list = check_meetings_have_live_in_progress(meetings_list, request) diff --git a/pod/cut/templates/video_cut.html b/pod/cut/templates/video_cut.html index 71af5234ba..6dbb8ceb64 100644 --- a/pod/cut/templates/video_cut.html +++ b/pod/cut/templates/video_cut.html @@ -128,14 +128,14 @@

    {% endif %}
    -

    {% trans "Help"%}

    - -
    -

    {% trans 'The video cut allows you to set a start and an end to trim your video.' %}

    -

    {% trans 'Your original video is kept and you can therefore modify your changes at any time.' %}

    -

    {% trans 'When saving your cut, an encoding is restarted to replace the old one.' %}

    +

    {% trans "Help"%}

    + +
    +

    {% trans 'The video cut allows you to set a start and an end to trim your video.' %}

    +

    {% trans 'Your original video is kept and you can therefore modify your changes at any time.' %}

    +

    {% trans 'When saving your cut, an encoding is restarted to replace the old one.' %}

    {% endblock page_aside %} diff --git a/pod/live/templates/live/event_edit.html b/pod/live/templates/live/event_edit.html index c553c1e4f9..396955b525 100644 --- a/pod/live/templates/live/event_edit.html +++ b/pod/live/templates/live/event_edit.html @@ -206,7 +206,7 @@

    {% trans "Event planning" %}

    document.querySelectorAll('ul.timelist').forEach( (e) => { var $this = e; var originalHref = $this.querySelector('a').getAttribute('href'); - console.log(originalHref); + $this.querySelector('li').remove(); for (i=8; i <= 20; i++) { var newLink = '
  • \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -1497,6 +1497,7 @@ msgstr "Gérer la vidéo" #: pod/chapter/templates/video_chapter.html #: pod/completion/templates/video_completion.html #: pod/cut/templates/video_cut.html pod/dressing/templates/video_dressing.html +#: pod/quiz/templates/quiz/question_help_aside.html msgid "Help" msgstr "Aide" @@ -7281,7 +7282,7 @@ msgstr "Accéder aux listes de lecture" #: pod/playlist/templates/playlist/add_or_edit.html #: pod/playlist/templates/playlist/playlist_link.html -#: pod/playlist/tests/test_views.py pod/playlist/views.py +#: pod/playlist/tests/test_views.py msgid "Edit the playlist" msgstr "Éditer la liste de lecture" @@ -7612,6 +7613,11 @@ msgid "The data sent to create the playlist are invalid." msgstr "" "Les données envoyées pour créer la liste de lecture ne sont pas valides." +#: pod/playlist/views.py +#, python-brace-format +msgid "Edit playlist “{playlist.name}”" +msgstr "Éditer la liste de lecture « {playlist.name} »" + #: pod/playlist/views.py msgid "JSON in wrong format" msgstr "JSON au mauvais format" @@ -7850,23 +7856,19 @@ msgstr "Autoriser" msgid "Redaction" msgstr "Rédaction" -#: pod/quiz/forms.py +#: pod/quiz/forms.py pod/quiz/templates/quiz/question_help_aside.html msgid "Short answer" msgstr "Réponse courte" -#: pod/quiz/forms.py -msgid "Long answer" -msgstr "Réponse longue" - #: pod/quiz/forms.py msgid "Choice" msgstr "Choix" -#: pod/quiz/forms.py +#: pod/quiz/forms.py pod/quiz/templates/quiz/question_help_aside.html msgid "Single choice" msgstr "Choix unique" -#: pod/quiz/forms.py +#: pod/quiz/forms.py pod/quiz/templates/quiz/question_help_aside.html msgid "Multiple choice" msgstr "Choix multiples" @@ -7965,14 +7967,6 @@ msgstr "Question à réponse courte" msgid "Write a short answer." msgstr "Écrivez une réponse courte." -#: pod/quiz/forms.py pod/quiz/models.py -msgid "Long answer question" -msgstr "Question à réponse longue" - -#: pod/quiz/forms.py -msgid "Write a long answer." -msgstr "Écrivez une réponse longue." - #: pod/quiz/models.py msgid "Choose a video associated with the quiz." msgstr "Veuillez choisir une vidéo associée au quiz." @@ -8091,14 +8085,6 @@ msgstr "Veuillez choisir une réponse entre 1 et 250 caractères." msgid "Short answer questions" msgstr "Questions à réponse courte" -#: pod/quiz/models.py -msgid "Please choose an answer." -msgstr "Veuillez choisir une réponse." - -#: pod/quiz/models.py -msgid "Long answer questions" -msgstr "Questions à réponse longue" - #: pod/quiz/templates/quiz/create_edit_quiz.html msgid "Add a question" msgstr "Ajouter une question" @@ -8139,6 +8125,58 @@ msgstr "Supprimer la question n°" msgid "One or more errors have been found in the question." msgstr "Une ou plusieurs erreurs ont été trouvées dans la question." +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"For short answer questions, ensure responses are between 1 and 250 " +"characters. This type of question is ideal for brief, specific answers." +msgstr "" +"Pour les questions à réponse courte, veillez à ce que les réponses soient " +"comprises entre 1 et 250 caractères. Ce type de question est idéal pour des " +"réponses brèves et spécifiques." + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"For short answer questions, please provide a response between 1 and 250 " +"characters. Be concise and specific." +msgstr "" +"Pour les questions à réponse courte, veuillez fournir une réponse de 1 à 250 " +"caractères. Soyez concis et précis." + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"Single choice questions require participants to select one answer from a " +"list of options. This is useful for questions with a clear, correct answer." +msgstr "" +"Les questions à choix unique demandent aux participants de sélectionner une " +"réponse parmi une liste d'options. Elles sont utiles pour les questions dont " +"la réponse est claire et correcte." + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"For single choice questions, select only one answer from the provided " +"options. Read each option carefully before making your selection." +msgstr "" +"Pour les questions à choix unique, ne sélectionnez qu'une seule réponse " +"parmi les options proposées. Lisez attentivement chaque option avant de " +"faire votre choix." + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"Multiple choice questions allow participants to select more than one answer. " +"Use this type for questions where more than one option could be correct." +msgstr "" +"Les questions à choix multiples permettent aux participants de sélectionner " +"plusieurs réponses. Utilisez ce type de question pour les questions où " +"plusieurs options peuvent être correctes." + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"For multiple choice questions, select all answers that apply. There may be " +"more than one correct answer." +msgstr "" +"Pour les questions à choix multiples, sélectionnez toutes les réponses qui " +"s'appliquent. Il peut y avoir plus d'une réponse correcte." + #: pod/quiz/templates/quiz/video_quiz.html msgid "This quiz is in draft." msgstr "Ce quiz est en mode brouillon." @@ -8168,6 +8206,14 @@ msgstr "Erreur constatée dans le formulaire" msgid "For the question “%(title)s”, %(error)s." msgstr "Pour la question « %(title)s », %(error)s." +#: pod/quiz/templates/quiz/video_quiz.html +msgid "" +"The creator of this quiz has decided not to display the answers and your " +"score." +msgstr "" +"Le créateur de ce quiz a décidé de ne pas afficher les réponses et votre " +"score." + #: pod/quiz/templates/quiz/video_quiz.html msgid "Correct answer:" msgstr "Réponse correcte :" @@ -8973,7 +9019,7 @@ msgstr "Dans le mode « Public », le contenu est visible par tout le monde." #: pod/video/forms.py msgid "" "In “Draft / Private” mode, the content shows nowhere and nobody else but you " -"can see it. You can add tokens for allow direct access by link." +"can see it." msgstr "" "En mode “Brouillon / Privé“, le contenu n'apparaît nulle part et personne " "d'autre que vous ne peut le voir. Vous pouvez ajouter des jetons pour " @@ -8983,15 +9029,18 @@ msgstr "" msgid "" "In “Restricted access” mode, you can choose the restrictions for the video." msgstr "" -"En mode « Accès restreint », vous pouvez choisir les restrictions pour la video." +"En mode « Accès restreint », vous pouvez choisir les restrictions pour la " +"video." #: pod/video/forms.py msgid "" "In “Restricted access” mode, you can add a password which will be asked to " -"anybody willing to watch your content." +"anybody willing to watch your content. You can add tokens for allow direct " +"access by link." msgstr "" -"En mode « Accès restreint », vous pouvez ajouter un mot de passe qui sera demandé à " -"toute personne souhaitant regarder votre contenu." +"En mode « Accès restreint », vous pouvez ajouter un mot de passe qui sera " +"demandé à toute personne souhaitant regarder votre contenu. Vous pouvez " +"ajouter des jetons pour permettre un accès direct par lien." #: pod/video/forms.py msgid "" @@ -9824,6 +9873,11 @@ 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 +msgid "This content contains a quiz." +msgstr "Ce contenu contient un quiz." + #: pod/video/templates/videos/card.html #: pod/video/templates/videos/card_select.html #: pod/video/templatetags/video_tags.py @@ -11128,6 +11182,21 @@ msgstr "Résultats de la recherche" msgid "Esup-Pod xAPI" msgstr "xAPI Esup-Pod" +#~ msgid "Long answer" +#~ msgstr "Réponse longue" + +#~ msgid "Long answer question" +#~ msgstr "Question à réponse longue" + +#~ msgid "Write a long answer." +#~ msgstr "Écrivez une réponse longue." + +#~ msgid "Please choose an answer." +#~ msgstr "Veuillez choisir une réponse." + +#~ msgid "Long answer questions" +#~ msgstr "Questions à réponse longue" + #~ msgid "Restricted" #~ msgstr "Restreint" diff --git a/pod/locale/fr/LC_MESSAGES/djangojs.mo b/pod/locale/fr/LC_MESSAGES/djangojs.mo index 3204dc61b61c1b3cf7c2819e08572e8f20ed9e66..50aa0419ad3ad018e5913b613736a5571400ad00 100644 GIT binary patch delta 4498 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 A$N&HU diff --git a/pod/locale/fr/LC_MESSAGES/djangojs.po b/pod/locale/fr/LC_MESSAGES/djangojs.po index c9fcecf1dc..ea75159d85 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-07-17 08:31+0000\n" +"POT-Creation-Date: 2024-07-17 09:34+0000\n" "PO-Revision-Date: \n" "Last-Translator: AymericJak \n" "Language-Team: \n" @@ -659,14 +659,6 @@ msgstr "La réponse courte" msgid "Short answer" msgstr "Réponse courte" -#: pod/quiz/static/quiz/js/create-quiz.js -msgid "The long answer" -msgstr "La réponse longue" - -#: pod/quiz/static/quiz/js/create-quiz.js -msgid "Long answer" -msgstr "Réponse longue" - #: pod/quiz/static/quiz/js/create-quiz.js #, javascript-format msgid "Select the choice #%s as correct answer." @@ -979,3 +971,9 @@ msgstr "Ajouts en favoris total depuis la création" #: pod/video/static/js/video_stats_view.js msgid "Slug" msgstr "Titre court" + +#~ msgid "The long answer" +#~ msgstr "La réponse longue" + +#~ msgid "Long answer" +#~ msgstr "Réponse longue" diff --git a/pod/locale/nl/LC_MESSAGES/django.po b/pod/locale/nl/LC_MESSAGES/django.po index 40c278a090..0c9cb692b5 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-07-17 08:31+0000\n" +"POT-Creation-Date: 2024-07-17 09:34+0000\n" "PO-Revision-Date: 2024-07-04 17:54+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -1418,6 +1418,7 @@ msgstr "" #: pod/chapter/templates/video_chapter.html #: pod/completion/templates/video_completion.html #: pod/cut/templates/video_cut.html pod/dressing/templates/video_dressing.html +#: pod/quiz/templates/quiz/question_help_aside.html msgid "Help" msgstr "" @@ -6791,7 +6792,7 @@ msgstr "" #: pod/playlist/templates/playlist/add_or_edit.html #: pod/playlist/templates/playlist/playlist_link.html -#: pod/playlist/tests/test_views.py pod/playlist/views.py +#: pod/playlist/tests/test_views.py msgid "Edit the playlist" msgstr "" @@ -7111,6 +7112,11 @@ msgstr "" msgid "The data sent to create the playlist are invalid." msgstr "" +#: pod/playlist/views.py +#, python-brace-format +msgid "Edit playlist “{playlist.name}”" +msgstr "" + #: pod/playlist/views.py msgid "JSON in wrong format" msgstr "" @@ -7343,23 +7349,19 @@ msgstr "" msgid "Redaction" msgstr "" -#: pod/quiz/forms.py +#: pod/quiz/forms.py pod/quiz/templates/quiz/question_help_aside.html msgid "Short answer" msgstr "" -#: pod/quiz/forms.py -msgid "Long answer" -msgstr "" - #: pod/quiz/forms.py msgid "Choice" msgstr "" -#: pod/quiz/forms.py +#: pod/quiz/forms.py pod/quiz/templates/quiz/question_help_aside.html msgid "Single choice" msgstr "" -#: pod/quiz/forms.py +#: pod/quiz/forms.py pod/quiz/templates/quiz/question_help_aside.html msgid "Multiple choice" msgstr "" @@ -7454,14 +7456,6 @@ msgstr "" msgid "Write a short answer." msgstr "" -#: pod/quiz/forms.py pod/quiz/models.py -msgid "Long answer question" -msgstr "" - -#: pod/quiz/forms.py -msgid "Write a long answer." -msgstr "" - #: pod/quiz/models.py msgid "Choose a video associated with the quiz." msgstr "" @@ -7575,14 +7569,6 @@ msgstr "" msgid "Short answer questions" msgstr "" -#: pod/quiz/models.py -msgid "Please choose an answer." -msgstr "" - -#: pod/quiz/models.py -msgid "Long answer questions" -msgstr "" - #: pod/quiz/templates/quiz/create_edit_quiz.html msgid "Add a question" msgstr "" @@ -7621,6 +7607,42 @@ msgstr "" msgid "One or more errors have been found in the question." msgstr "" +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"For short answer questions, ensure responses are between 1 and 250 " +"characters. This type of question is ideal for brief, specific answers." +msgstr "" + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"For short answer questions, please provide a response between 1 and 250 " +"characters. Be concise and specific." +msgstr "" + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"Single choice questions require participants to select one answer from a " +"list of options. This is useful for questions with a clear, correct answer." +msgstr "" + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"For single choice questions, select only one answer from the provided " +"options. Read each option carefully before making your selection." +msgstr "" + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"Multiple choice questions allow participants to select more than one answer. " +"Use this type for questions where more than one option could be correct." +msgstr "" + +#: pod/quiz/templates/quiz/question_help_aside.html +msgid "" +"For multiple choice questions, select all answers that apply. There may be " +"more than one correct answer." +msgstr "" + #: pod/quiz/templates/quiz/video_quiz.html msgid "This quiz is in draft." msgstr "" @@ -7650,6 +7672,12 @@ msgstr "" msgid "For the question “%(title)s”, %(error)s." msgstr "" +#: pod/quiz/templates/quiz/video_quiz.html +msgid "" +"The creator of this quiz has decided not to display the answers and your " +"score." +msgstr "" + #: pod/quiz/templates/quiz/video_quiz.html msgid "Correct answer:" msgstr "" @@ -8398,7 +8426,8 @@ msgstr "" #: pod/video/forms.py msgid "" "In “Restricted access” mode, you can add a password which will be asked to " -"anybody willing to watch your content." +"anybody willing to watch your content. You can add tokens for allow direct " +"access by link." msgstr "" #: pod/video/forms.py @@ -9159,6 +9188,11 @@ msgstr "" msgid "The file extension is not in the allowed extension:" msgstr "" +#: pod/video/templates/videos/card.html +#: pod/video/templates/videos/card_select.html +msgid "This content contains a quiz." +msgstr "" + #: pod/video/templates/videos/card.html #: pod/video/templates/videos/card_select.html #: pod/video/templatetags/video_tags.py diff --git a/pod/locale/nl/LC_MESSAGES/djangojs.po b/pod/locale/nl/LC_MESSAGES/djangojs.po index 3f193d10b0..35f9464b00 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-07-17 08:31+0000\n" +"POT-Creation-Date: 2024-07-17 09:34+0000\n" "PO-Revision-Date: 2024-06-04 16:20+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -625,14 +625,6 @@ msgstr "" msgid "Short answer" msgstr "" -#: pod/quiz/static/quiz/js/create-quiz.js -msgid "The long answer" -msgstr "" - -#: pod/quiz/static/quiz/js/create-quiz.js -msgid "Long answer" -msgstr "" - #: pod/quiz/static/quiz/js/create-quiz.js #, javascript-format msgid "Select the choice #%s as correct answer." diff --git a/pod/playlist/forms.py b/pod/playlist/forms.py index c975b7b023..d88c634013 100644 --- a/pod/playlist/forms.py +++ b/pod/playlist/forms.py @@ -146,9 +146,8 @@ def __init__(self, *args, **kwargs) -> None: super(PlaylistForm, self).__init__(*args, **kwargs) self.fields = add_placeholder_and_asterisk(self.fields) if self.user: - if not self.user.is_staff and RESTRICT_PROMOTED_PLAYLIST_ACCESS_TO_STAFF_ONLY: - if "promoted" in self.fields: - del self.fields["promoted"] + if RESTRICT_PROMOTED_PLAYLIST_ACCESS_TO_STAFF_ONLY and "promoted" in self.fields or self.user.is_superuser: + del self.fields["promoted"] else: if "promoted" in self.fields: del self.fields["promoted"] diff --git a/pod/playlist/templatetags/playlist_buttons.py b/pod/playlist/templatetags/playlist_buttons.py index 9eb50dac6b..2067b3e5ae 100644 --- a/pod/playlist/templatetags/playlist_buttons.py +++ b/pod/playlist/templatetags/playlist_buttons.py @@ -30,7 +30,7 @@ def user_can_edit_or_remove(context: dict, playlist: Playlist) -> bool: return False return playlist.editable and ( request.user == playlist.owner - or request.user.is_staff + or request.user.is_superuser or request.user in get_additional_owners(playlist) ) diff --git a/pod/playlist/utils.py b/pod/playlist/utils.py index 4c6d027022..dad2174204 100644 --- a/pod/playlist/utils.py +++ b/pod/playlist/utils.py @@ -228,7 +228,7 @@ def remove_playlist(user: User, playlist: Playlist) -> None: user (:class:`django.contrib.auth.models.User`): The user object playlist (:class:`pod.playlist.models.Playlist`): The playlist objet """ - if playlist.owner == user or user.is_staff: + if playlist.owner == user or user.is_superuser: playlist.delete() @@ -415,7 +415,7 @@ def playlist_can_be_displayed(request: WSGIRequest, playlist: Playlist) -> bool: request.user.is_authenticated and ( playlist.owner == request.user + or request.user.is_superuser or playlist in get_playlists_for_additional_owner(request.user) - or request.user.is_staff ) ) diff --git a/pod/playlist/views.py b/pod/playlist/views.py index 0bd982133c..53f794fb50 100644 --- a/pod/playlist/views.py +++ b/pod/playlist/views.py @@ -470,11 +470,11 @@ def handle_get_request_for_add_or_edit_function(request: WSGIRequest, slug: str) if playlist: if ( request.user == playlist.owner - or request.user.is_staff + or request.user.is_superuser or request.user in get_additional_owners(playlist) ) and playlist.editable: form = PlaylistForm(instance=playlist, user=request.user) - page_title = _("Edit the playlist") + f' "{playlist.name}"' + page_title = _(f"Edit playlist “{playlist.name}”") else: return redirect(reverse("playlist:list")) else: diff --git a/pod/quiz/admin.py b/pod/quiz/admin.py index 6b9137b98a..fe763dc69e 100644 --- a/pod/quiz/admin.py +++ b/pod/quiz/admin.py @@ -3,7 +3,6 @@ from django.contrib import admin from pod.quiz.models import ( - LongAnswerQuestion, MultipleChoiceQuestion, Quiz, ShortAnswerQuestion, @@ -39,11 +38,6 @@ class MultipleChoiceQuestionAdmin(BaseQuestionAdmin): """Admin configuration for MultipleChoiceQuestion.""" -@admin.register(LongAnswerQuestion) -class LongAnswerQuestionAdmin(BaseQuestionAdmin): - """Admin configuration for LongAnswerQuestion.""" - - @admin.register(ShortAnswerQuestion) class ShortAnswerQuestionAdmin(BaseQuestionAdmin): """Admin configuration for ShortAnswerQuestion.""" @@ -66,13 +60,6 @@ class MultipleChoiceQuestionInline(admin.StackedInline): extra = 0 -class LongAnswerQuestionInline(admin.StackedInline): - """Inline configuration for LongAnswerQuestion.""" - - model = LongAnswerQuestion - extra = 0 - - class ShortAnswerQuestionInline(admin.StackedInline): """Inline configuration for ShortAnswerQuestion.""" @@ -91,6 +78,5 @@ class QuizAdmin(admin.ModelAdmin): inlines = [ SingleChoiceQuestionInline, MultipleChoiceQuestionInline, - LongAnswerQuestionInline, ShortAnswerQuestionInline, ] diff --git a/pod/quiz/apps.py b/pod/quiz/apps.py index 780fb94772..d0af8bb08d 100644 --- a/pod/quiz/apps.py +++ b/pod/quiz/apps.py @@ -1,7 +1,18 @@ """Esup-Pod quiz app.""" from django.apps import AppConfig +from django.db import connection +from django.db.models.signals import pre_migrate, post_migrate from django.utils.translation import gettext_lazy as _ +from django.db import transaction + +QUESTION_DATA = { + 'single_choice': [], + 'multiple_choice': [], + 'short_answer': [], +} + +EXECUTE_QUIZ_MIGRATIONS = False class QuizConfig(AppConfig): @@ -10,3 +21,112 @@ class QuizConfig(AppConfig): name = "pod.quiz" default_auto_field = "django.db.models.BigAutoField" verbose_name = _("Quiz") + + def ready(self) -> None: + pre_migrate.connect(self.check_quiz_migrations, sender=self) + pre_migrate.connect(self.save_previous_questions, sender=self) + pre_migrate.connect(self.remove_previous_questions, sender=self) + post_migrate.connect(self.send_previous_questions, sender=self) + + def execute_query_mapping(self, query, mapping_dict, question_type) -> None: + """ + Execute the given query and populate the mapping dictionary with the results. + + Args: + query (str): The given query to execute + mapping_dict (dict): The dictionary. + """ + from pod.quiz.models import Quiz + import json + + try: + with connection.cursor() as c: + c.execute(query) + results = c.fetchall() + for question_result in results: + quiz = Quiz.objects.get(id=question_result[6]) + question_data = question_result[5] + if question_type in {"single_choice", "multiple_choice"}: + question_data = json.loads(question_data) + mapping_dict.append({ + "question_type": question_type, "quiz": quiz, "title": question_result[1], "explanation": question_result[2], "start_timestamp": question_result[3], "end_timestamp": question_result[4], "question_data": question_data + }) + except Exception as e: + print(e) + pass + + def check_quiz_migrations(self, sender, **kwargs) -> None: + """Save previous questions from different tables.""" + from pod.quiz.models import MultipleChoiceQuestion, ShortAnswerQuestion, SingleChoiceQuestion + QUESTION_MODELS = [MultipleChoiceQuestion, ShortAnswerQuestion, SingleChoiceQuestion] + + global EXECUTE_QUIZ_MIGRATIONS + quiz_exist = self.check_quiz_exist() + if not quiz_exist: + return + + for model in QUESTION_MODELS: + query = f"SELECT id FROM {model.objects.model._meta.db_table} LIMIT 1" + try: + with connection.cursor() as cursor: + cursor.execute(query) + result = cursor.fetchone() + if result and isinstance(result[0], int): + EXECUTE_QUIZ_MIGRATIONS = True + break + except Exception as e: + print(e) + pass + + def check_quiz_exist(self) -> bool: + """Check if quiz exist in database.""" + from pod.quiz.models import Quiz + try: + quiz = Quiz.objects.first() + if not quiz: + return False + return True + except Exception: + return False + + def save_previous_questions(self, sender, **kwargs) -> None: + """Save previous questions from different tables.""" + from pod.quiz.models import MultipleChoiceQuestion, ShortAnswerQuestion, SingleChoiceQuestion + + if not EXECUTE_QUIZ_MIGRATIONS: + return + + queries = { + "single_choice": f"SELECT id, title, explanation, start_timestamp, end_timestamp, choices, quiz_id FROM {SingleChoiceQuestion.objects.model._meta.db_table}", + "multiple_choice": f"SELECT id, title, explanation, start_timestamp, end_timestamp, choices, quiz_id FROM {MultipleChoiceQuestion.objects.model._meta.db_table}", + "short_answer": f"SELECT id, title, explanation, start_timestamp, end_timestamp, answer, quiz_id FROM {ShortAnswerQuestion.objects.model._meta.db_table}", + } + for question_type, query in queries.items(): + self.execute_query_mapping(query, QUESTION_DATA[question_type], question_type) + + def remove_previous_questions(self, sender, **kwargs) -> None: + """Remove previous questions from different tables.""" + from pod.quiz.models import MultipleChoiceQuestion, ShortAnswerQuestion, SingleChoiceQuestion + + if not EXECUTE_QUIZ_MIGRATIONS: + return + + QUESTION_MODELS = [MultipleChoiceQuestion, ShortAnswerQuestion, SingleChoiceQuestion] + + for model in QUESTION_MODELS: + model.objects.all().delete() + print("--- Previous questions deleted successfuly ---") + + @transaction.atomic + def send_previous_questions(self, sender, **kwargs) -> None: + """Insert previously saved questions from QUESTION_DATA.""" + from pod.quiz.views import create_question + + if not EXECUTE_QUIZ_MIGRATIONS: + return + + for question_type, questions in QUESTION_DATA.items(): + for question in questions: + print(question["question_data"]) + create_question(question_type=question_type, quiz=question["quiz"], title=question["title"], explanation=question["explanation"], start_timestamp=question["start_timestamp"], end_timestamp=question["end_timestamp"], question_data=question["question_data"]) + print("--- New questions saved successfuly ---") diff --git a/pod/quiz/forms.py b/pod/quiz/forms.py index 3e31416a22..5f3fc9e05d 100644 --- a/pod/quiz/forms.py +++ b/pod/quiz/forms.py @@ -7,7 +7,6 @@ import json from pod.quiz.models import ( - LongAnswerQuestion, MultipleChoiceQuestion, ShortAnswerQuestion, SingleChoiceQuestion, @@ -22,7 +21,6 @@ class QuestionForm(forms.Form): _("Redaction"), [ ("short_answer", _("Short answer")), - ("long_answer", _("Long answer")), ], ), ( @@ -74,7 +72,7 @@ class QuestionForm(forms.Form): required=True, help_text=_("Please choose the question type."), ) - question_id = forms.IntegerField( + question_id = forms.UUIDField( widget=forms.HiddenInput(), required=False, ) @@ -88,11 +86,6 @@ class QuestionForm(forms.Form): required=False, label=_("Short answer"), ) - long_answer = forms.CharField( - widget=forms.HiddenInput(attrs={"class": "hidden-long-answer-field"}), - required=False, - label=_("Long answer"), - ) multiple_choice = forms.CharField( widget=forms.HiddenInput(attrs={"class": "hidden-multiple-choice-field"}), required=False, @@ -283,25 +276,3 @@ def __init__(self, *args, **kwargs) -> None: """Init short answer question form.""" super(ShortAnswerQuestionForm, self).__init__(*args, **kwargs) self.fields = add_placeholder_and_asterisk(self.fields) - - -class LongAnswerQuestionForm(forms.ModelForm): - """Form to show a long answer question form.""" - - user_answer = forms.CharField( - label=_("Long answer question"), - widget=forms.Textarea(), - required=True, - help_text=_("Write a long answer."), - ) - - class Meta: - """LongAnswerQuestionForm Metadata.""" - - model = LongAnswerQuestion - fields = ["user_answer"] - - def __init__(self, *args, **kwargs) -> None: - """Init long answer question form.""" - super(LongAnswerQuestionForm, self).__init__(*args, **kwargs) - self.fields = add_placeholder_and_asterisk(self.fields) diff --git a/pod/quiz/models.py b/pod/quiz/models.py index c7b6cf7ed2..1c812d5524 100644 --- a/pod/quiz/models.py +++ b/pod/quiz/models.py @@ -1,5 +1,6 @@ """Esup-Pod quiz models.""" +import uuid from json import JSONDecodeError, loads from django.core.exceptions import ValidationError from django.db import models @@ -86,7 +87,7 @@ class Question(models.Model): start_timestamp (IntegerField): Start timestamp of the answer in the video. end_timestamp (IntegerField): End timestamp of the answer in the video. """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) quiz = models.ForeignKey( Quiz, verbose_name=_("Quiz"), @@ -162,7 +163,7 @@ def get_question_form(self, data=None): Returns: QuestionForm: Form for the question. """ - return "This method must be redefined in child class." + raise NotImplementedError("Subclasses of Question must implement get_question_form method.") def get_answer(self) -> None: """ @@ -171,7 +172,7 @@ def get_answer(self) -> None: Returns: str: Answer for the question. """ - return None + raise NotImplementedError("Subclasses of Question must implement get_answer method.") def get_type(self) -> None: """ @@ -180,7 +181,30 @@ def get_type(self) -> None: Returns: str: Type of the question. """ - return None + raise NotImplementedError("Subclasses of Question must implement get_type method.") + + def calculate_score(self, user_answer): + """ + Calculate the score for the question based on user's answer. + + Args: + user_answer (any): Answer provided by the user. + + Returns: + float: The calculated score, a value between 0 and 1. + """ + correct_answer = self.get_answer() + + if correct_answer is None: + return 0.0 + + return self._calculate_score(user_answer, correct_answer) + + def _calculate_score(self, user_answer, correct_answer): + """ + Internal method to be implemented by subclasses to calculate score. + """ + raise NotImplementedError("Subclasses of Question must implement _calculate_score method.") class SingleChoiceQuestion(Question): @@ -262,6 +286,19 @@ def get_question_form(self, data=None): return SingleChoiceQuestionForm(data, instance=self, prefix=f"question_{self.pk}") + def _calculate_score(self, user_answer, correct_answer): + """ + Calculate the score for single choice question. + + Args: + user_answer (any): Answer provided by the user. + correct_answer (any): Correct answer for the question. + + Returns: + float: The calculated score, a value between 0 and 1. + """ + return 1.0 if user_answer.lower() == correct_answer.lower() else 0.0 + class MultipleChoiceQuestion(Question): """ @@ -346,6 +383,27 @@ def get_question_form(self, data=None): data, instance=self, prefix=f"question_{self.pk}" ) + def _calculate_score(self, user_answer, correct_answer): + """ + Calculate the score for multiple choice question. + + Args: + user_answer (list): Answers provided by the user. + correct_answer (list): Correct answers for the question. + + Returns: + float: The calculated score, a value between 0 and 1. + """ + user_answer_set = set(user_answer) if user_answer else set() + correct_answer_set = set(correct_answer) + intersection = user_answer_set & correct_answer_set + score = len(intersection) / len(correct_answer_set) + + len_incorrect = len(user_answer_set - correct_answer_set) + penalty = len_incorrect / len(correct_answer_set) + score = max(0, score - penalty) + return score + class TrueFalseQuestion(Question): # TODO """ @@ -417,46 +475,17 @@ def get_question_form(self, data=None): return ShortAnswerQuestionForm(data, instance=self, prefix=f"question_{self.pk}") - -class LongAnswerQuestion(Question): - """ - Long answer question model. - - Attributes: - answer (TextField): Answer of the question. - """ - - answer = models.TextField( - verbose_name=_("Answer"), - default="", - help_text=_("Please choose an answer."), - ) - - class Meta: - verbose_name = _("Long answer question") - verbose_name_plural = _("Long answer questions") - - def __str__(self) -> str: - """Representation the LongAnswerQuestion as string.""" - return super().__str__() - - def get_answer(self) -> str: - return self.answer - - def get_type(self): - return "long_answer" - - def get_question_form(self, data=None): + def _calculate_score(self, user_answer, correct_answer): """ - Get the form for the question. + Calculate the score for short answer question. Args: - data (dict): Form data. + user_answer (str): Answer provided by the user. + correct_answer (str): Correct answer for the question. + Returns: - LongAnswerQuestionForm: Form for the question. + float: The calculated score, a value between 0 and 1. """ - from pod.quiz.forms import ( - LongAnswerQuestionForm, - ) - - return LongAnswerQuestionForm(data, instance=self, prefix=f"question_{self.pk}") + if user_answer is not None and user_answer.strip() != "" and correct_answer is not None: + return 1.0 if user_answer.lower() == correct_answer.lower() else 0.0 + return 0.0 diff --git a/pod/quiz/static/quiz/js/create-quiz.js b/pod/quiz/static/quiz/js/create-quiz.js index 8c8fbb4c11..05f5d48637 100644 --- a/pod/quiz/static/quiz/js/create-quiz.js +++ b/pod/quiz/static/quiz/js/create-quiz.js @@ -203,34 +203,6 @@ document.addEventListener("DOMContentLoaded", function () { questionChoicesForm.appendChild(input); } - /** - * Handles the setup for a long answer question in the question form. - * @param {HTMLElement} questionForm - The question form element. - */ - function handleLongAnswerQuestion(questionForm) { - const textarea = document.createElement("textarea"); - const textareaId = "long-answer-" + questionForm.getAttribute("data-question-index"); - const label = document.createElement("label"); - - textarea.id = textareaId; - textarea.name = textareaId; - textarea.required = true; - textarea.placeholder = gettext("The long answer"); - textarea.classList.add("long-answer-field", "form-control"); - - label.setAttribute("for", textareaId); - label.textContent = gettext("Long answer"); - - let qData = getQuestionData(questionForm); - if (qData && qData["long_answer"] != null) { - textarea.value = qData["long_answer"]; - } - - const questionChoicesForm = questionForm.querySelector(".question-choices-form"); - questionChoicesForm.appendChild(label); - questionChoicesForm.appendChild(textarea); - } - /** * Handles the setup for a single choice question in the question form. * @param {HTMLElement} questionForm - The question form element. @@ -480,9 +452,6 @@ document.addEventListener("DOMContentLoaded", function () { case "short_answer": handleShortAnswerQuestion(questionForm); break; - case "long_answer": - handleLongAnswerQuestion(questionForm); - break; case "single_choice": handleSingleChoiceQuestion(questionForm); break; @@ -564,9 +533,6 @@ document.addEventListener("DOMContentLoaded", function () { case "short_answer": handleShortAnswerSubmission(questionForm); break; - case "long_answer": - handleLongAnswerSubmission(questionForm); - break; case "single_choice": handleSingleChoiceSubmission(questionForm); break; @@ -596,19 +562,6 @@ document.addEventListener("DOMContentLoaded", function () { hiddenShortAnswerInput.value = shortAnswerInput.value; } - /** - * Handles the submission process for long answer questions in the quiz form. - * - * @param {HTMLElement} questionForm - The form element representing the long answer question. - */ - function handleLongAnswerSubmission(questionForm) { - let longAnswerInput = questionForm.querySelector(".long-answer-field"); - let hiddenLongAnswerInput = questionForm.querySelector( - ".hidden-long-answer-field", - ); - hiddenLongAnswerInput.value = longAnswerInput.value; - } - /** * Handles the submission process for single choice questions in the quiz form. * diff --git a/pod/quiz/static/quiz/js/video-quiz-submit.js b/pod/quiz/static/quiz/js/video-quiz-submit.js index 587220ec5d..b5baf46d54 100644 --- a/pod/quiz/static/quiz/js/video-quiz-submit.js +++ b/pod/quiz/static/quiz/js/video-quiz-submit.js @@ -9,7 +9,7 @@ global player */ // Read-only globals defined in video_quiz.html /* -global questions_answers +global questions_answers, show_correct_answers */ const questionList = document.querySelectorAll(".question-container"); @@ -18,10 +18,10 @@ for (let questionElement of questionList) { let showResponseButton = questionElement.querySelector( ".show-response-button", ); - if(showResponseButton) { + if (showResponseButton) { showResponseButton.addEventListener("click", function (event) { event.preventDefault(); - if(player.paused()) { + if (player.paused()) { player.play(); } player.currentTime(this.attributes.start.value); @@ -34,41 +34,40 @@ for (let questionElement of questionList) { // if answer in good answer, put it in green else if user answer put it in red let allanswers = questionElement.querySelectorAll(`ul#id_${questionid}-selected_choice li input`); for (let answer of allanswers) { - answer.disabled=true; + answer.disabled = true; if (questions_answers[`${questionid}`]) { let user_answer = questions_answers[`${questionid}`][0]; - let correct_answer = questions_answers[`${questionid}`][1]; - if( (Array.isArray(correct_answer) && correct_answer.includes(answer.value)) || correct_answer === answer.value ){ - answer.closest('li').classList.add('bi', 'bi-clipboard-check', 'text-success'); - answer.closest('li').title=gettext("Correct answer given"); - } else if ((Array.isArray(user_answer) && user_answer.includes(answer.value)) || user_answer === answer.value ){ - answer.closest('li').classList.add('bi', 'bi-clipboard-x', 'text-danger'); - answer.closest('li').title=gettext("Incorrect answer given"); - } else { - answer.closest('li').classList.add('bi', 'bi-clipboard'); - } - // check if question is "single_choice", "multiple_choice", "short_answer", "long_answer" - if ((Array.isArray(user_answer) && user_answer.includes(answer.value)) || user_answer === answer.value ){ + // check if question is "single_choice", "multiple_choice", "short_answer" + if ((Array.isArray(user_answer) && user_answer.includes(answer.value)) || user_answer === answer.value) { answer.checked = true; } + if (show_correct_answers) { + let correct_answer = questions_answers[`${questionid}`][1]; + if ((Array.isArray(correct_answer) && correct_answer.includes(answer.value)) || correct_answer === answer.value) { + answer.closest('li').classList.add('bi', 'bi-clipboard-check', 'text-success'); + answer.closest('li').title = gettext("Correct answer given"); + } else if ((Array.isArray(user_answer) && user_answer.includes(answer.value)) || user_answer === answer.value) { + answer.closest('li').classList.add('bi', 'bi-clipboard-x', 'text-danger'); + answer.closest('li').title = gettext("Incorrect answer given"); + } else { + answer.closest('li').classList.add('bi', 'bi-clipboard'); + } + } } } // case user input - // Get short or long answer input + // Get short answer input let user_input = document.getElementById(`id_${questionid}-user_answer`); if (user_input) { user_input.disabled = true; } allanswersarr = Array.from(allanswers) - if(user_input && !allanswersarr.includes(user_input) && questions_answers[`${questionid}`]) { - let user_answer = questions_answers[`${questionid}`][0]; - if(user_input.tagName === 'INPUT') { - user_input.value = user_answer; - } - if(user_input.tagName === 'TEXTAREA') { - user_input.innerText = user_answer; - } + if (user_input && !allanswersarr.includes(user_input) && questions_answers[`${questionid}`]) { + let user_answer = questions_answers[`${questionid}`][0]; + if (user_input.tagName === 'INPUT') { + user_input.value = user_answer; + } } } diff --git a/pod/quiz/templates/quiz/create_edit_quiz.html b/pod/quiz/templates/quiz/create_edit_quiz.html index 1dbb7a9f06..ee82d619bb 100644 --- a/pod/quiz/templates/quiz/create_edit_quiz.html +++ b/pod/quiz/templates/quiz/create_edit_quiz.html @@ -104,6 +104,8 @@

    {% include "main/mandatory_fields.html" %} + {% include "quiz/question_help_aside.html" with creator=True %} + {% endblock page_aside %} {% block more_script %} diff --git a/pod/quiz/templates/quiz/question_help_aside.html b/pod/quiz/templates/quiz/question_help_aside.html new file mode 100644 index 0000000000..d90f8a2646 --- /dev/null +++ b/pod/quiz/templates/quiz/question_help_aside.html @@ -0,0 +1,45 @@ +{% load i18n %} + +
    +

    {% trans "Help"%}

    + + +
    +

    + {% if creator is True %} + {% trans "For short answer questions, ensure responses are between 1 and 250 characters. This type of question is ideal for brief, specific answers." %} + {% else %} + {% trans "For short answer questions, please provide a response between 1 and 250 characters. Be concise and specific." %} + {% endif %} +

    +
    + + +
    +

    + {% if creator is True %} + {% trans "Single choice questions require participants to select one answer from a list of options. This is useful for questions with a clear, correct answer." %} + {% else %} + {% trans "For single choice questions, select only one answer from the provided options. Read each option carefully before making your selection." %} + {% endif %} +

    +
    + + +
    +

    + {% if creator is True %} + {% trans "Multiple choice questions allow participants to select more than one answer. Use this type for questions where more than one option could be correct." %} + {% else %} + {% trans "For multiple choice questions, select all answers that apply. There may be more than one correct answer." %} + {% endif %} +

    +
    + +
    diff --git a/pod/quiz/templates/quiz/video_quiz.html b/pod/quiz/templates/quiz/video_quiz.html index a625097151..e0c645e5d0 100644 --- a/pod/quiz/templates/quiz/video_quiz.html +++ b/pod/quiz/templates/quiz/video_quiz.html @@ -28,7 +28,7 @@

  • {% endif %} - {% if form_submitted and questions_form_errors.items|length == 0 %} + {% if quiz.show_correct_answers and form_submitted and questions_form_errors.items|length == 0 %} {% if percentage_score >= 75 %} + {% elif not quiz.show_correct_answers %} + {% endif %}
    @@ -62,7 +66,7 @@ {% csrf_token %}
    {% for question in quiz.get_questions %} -