From f055954e419d0a5852a64abafc92fc69760bd629 Mon Sep 17 00:00:00 2001 From: Aymeric Jakobowski <106442526+AymericJak@users.noreply.github.com> Date: Tue, 7 May 2024 15:31:16 +0200 Subject: [PATCH] [WIP] Bugfixes for 3.6.1 (#1120) * Fix DownloadFileForm * improve encoding video model * improve video info downloads * add command to import encoded video * Fix rights for dressings * Fix unit test & flake8 * add class iframe for the body in video iframe template and remove test in encoding model * Rename sendAndGetForm function * Add logs * :bug: Fix the add contributor button in the completion page * Improve flake8 * Improve flake8 * Fix playlists views * Fix type * :bug: Fix the cancel button in the completion forms * remove unused command * Fix some playlist views * Improve meetings * Fix playlist tests * add space between line in subtitle * move css code to existing place * :bug: Fix the playlist right * :bug: Fix the unit tests * Remove debug logs * Add JSDoc * Add line break at the file end * Fix channel in dashboard * bump version to 3.6.1 --------- Co-authored-by: Ptitloup Co-authored-by: SebastienCozeDev --- .github/workflows/pod_dev.yml | 2 +- pod/authentication/utils.py | 2 +- pod/completion/static/js/completion.js | 61 ++++++-------- .../contributor/form_contributor.html | 2 +- .../templates/document/form_document.html | 2 +- .../templates/overlay/form_overlay.html | 2 +- .../templates/track/form_track.html | 2 +- pod/dressing/views.py | 13 +-- pod/live/live_transcript.py | 2 +- pod/locale/fr/LC_MESSAGES/django.mo | Bin 201297 -> 201447 bytes pod/locale/fr/LC_MESSAGES/django.po | 41 +++++----- pod/locale/fr/LC_MESSAGES/djangojs.po | 2 +- pod/locale/nl/LC_MESSAGES/django.po | 33 ++++---- pod/locale/nl/LC_MESSAGES/djangojs.po | 69 ++++++++++++++-- pod/main/forms.py | 19 +++-- pod/main/static/css/pod.css | 1 + pod/main/tests/test_views.py | 10 ++- pod/meeting/static/css/meeting.css | 12 +-- pod/meeting/static/js/my_meetings.js | 2 +- pod/meeting/templates/meeting/invite.html | 74 ++++++++--------- .../templates/meeting/meeting_card.html | 6 +- pod/meeting/views.py | 9 ++- pod/playlist/models.py | 2 +- .../templates/playlist/playlist_player.html | 2 +- pod/playlist/templatetags/playlist_buttons.py | 4 +- pod/playlist/tests/test_views.py | 21 +---- pod/playlist/utils.py | 15 ++-- pod/playlist/views.py | 76 ++++++++++-------- pod/recorder/plugins/type_studio.py | 2 +- pod/recorder/views.py | 4 +- pod/settings.py | 2 +- .../commands/import_encoded_video.py | 43 ---------- pod/video/static/js/dashboard.js | 8 ++ pod/video/templates/videos/card.html | 2 +- pod/video/templates/videos/link_video.html | 2 +- pod/video/templates/videos/video-iframe.html | 2 +- pod/video/templates/videos/video-info.html | 4 +- .../templates/videos/video_row_select.html | 2 +- pod/video/utils.py | 2 +- pod/video/views.py | 16 ++-- .../Encoding_video_model.py | 27 +++++-- .../commands/import_encode_video.py | 19 +++++ .../transcript_model.py | 2 +- setup.cfg | 9 ++- 44 files changed, 351 insertions(+), 281 deletions(-) delete mode 100755 pod/video/management/commands/import_encoded_video.py create mode 100644 pod/video_encode_transcript/management/commands/import_encode_video.py diff --git a/.github/workflows/pod_dev.yml b/.github/workflows/pod_dev.yml index e15429666a..7454c3c4c4 100644 --- a/.github/workflows/pod_dev.yml +++ b/.github/workflows/pod_dev.yml @@ -66,7 +66,7 @@ jobs: # FLAKE 8 - name: Flake8 compliance run: | - flake8 --max-complexity=7 --ignore=E501,W503,E203 --exclude .git,pod/*/migrations/*.py,*_settings.py + flake8 --max-complexity=7 --ignore=E501,W503,E203 --exclude .git,pod/*/migrations/*.py,test_settings.py,node_modules/*/*.py,pod/static/*.py,pod/custom/tenants/*/*.py ## Start remote encoding and transcoding test ## - name: Create settings local file diff --git a/pod/authentication/utils.py b/pod/authentication/utils.py index 74f5f62e9d..fdf71e0398 100644 --- a/pod/authentication/utils.py +++ b/pod/authentication/utils.py @@ -30,6 +30,6 @@ def get_owners(search, limit, offset): "first_name", "last_name", ) - )[offset : limit + offset] + )[offset: limit + offset] return JsonResponse(users, safe=False) diff --git a/pod/completion/static/js/completion.js b/pod/completion/static/js/completion.js index 18a50c7577..66d8798e0a 100644 --- a/pod/completion/static/js/completion.js +++ b/pod/completion/static/js/completion.js @@ -28,34 +28,6 @@ document.addEventListener("DOMContentLoaded", function () { var num = 0; var name = ""; -// RESET -document.addEventListener("reset", (event) => { - if (!event.target.matches("#accordeon form.completion")) return; - - var id_form = event.target.getAttribute("id"); - var name_form = id_form.substring(5, id_form.length); - var form_new = "form_new_" + name_form; - var list = "list_" + name_form; - document.getElementById(id_form).innerHTML = ""; - if (id_form == "form_track") - document.getElementById("form_track").style.width = "auto"; - document.getElementById(form_new).style.display = "block"; - document.querySelectorAll("form").forEach((form) => { - form.style.display = "block"; - }); - document.querySelectorAll("a.title").forEach(function (element) { - element.style.display = "initial"; - }); - document.querySelectorAll("table tr").forEach(function (element) { - element.classList.remove("info"); - }); - - let fileModalDoc = document.getElementById("fileModal_id_document"); - let fileModalSrc = document.getElementById("fileModal_id_src"); - - fileModalDoc?.remove(); - fileModalSrc?.remove(); -}); function show_form(data, form) { let form_el = document.getElementById(form); @@ -95,10 +67,10 @@ var ajaxfail = function (data, form) { document.addEventListener("submit", (e) => { if ( - e.target.id != "form_new_contributor" && - e.target.id != "form_new_document" && - e.target.id != "form_new_track" && - e.target.id != "form_new_overlay" && + e.target.id !== "form_new_contributor" && + e.target.id !== "form_new_document" && + e.target.id !== "form_new_track" && + e.target.id !== "form_new_overlay" && !e.target.matches(".form_change") && !e.target.matches(".form_delete") ) @@ -123,12 +95,24 @@ document.addEventListener("submit", (e) => { var form = "form_" + name_form; var list = "list_" + name_form; var action = e.target.querySelector("input[name=action]").value; - sendandgetform(e.target, action, name_form, form, list); + sendAndGetForm(e.target, action, name_form, form, list).then(r => "").catch(e => console.log("error", e)); }); -var sendandgetform = async function (elt, action, name, form, list) { + +/** + * Send and get form. + * + * @param elt {HTMLElement} HTML element. + * @param action {string} Action. + * @param name {string} Name. + * @param form {string} Form. + * @param list {string} List. + * + * @return {Promise} The form promise. + */ +var sendAndGetForm = async function (elt, action, name, form, list) { var href = elt.getAttribute("action"); - if (action == "new" || action == "form_save_new") { + if (action === "new" || action === "form_save_new") { document.getElementById(form).innerHTML = '
'; @@ -142,8 +126,13 @@ var sendandgetform = async function (elt, action, name, form, list) { }).hide(); }); /* Display associated help in side menu */ + console.log("name", name); var compInfo = document.querySelector(`#${name}-info>.collapse`); - bootstrap.Collapse.getOrCreateInstance(compInfo).show(); + try { + bootstrap.Collapse.getOrCreateInstance(compInfo).show(); + } catch (e) { + // do nothing + } let url = window.location.origin + href; let token = elt.csrfmiddlewaretoken.value; diff --git a/pod/completion/templates/contributor/form_contributor.html b/pod/completion/templates/contributor/form_contributor.html index 52b73901a0..fe5d3431b0 100644 --- a/pod/completion/templates/contributor/form_contributor.html +++ b/pod/completion/templates/contributor/form_contributor.html @@ -25,7 +25,7 @@ diff --git a/pod/completion/templates/document/form_document.html b/pod/completion/templates/document/form_document.html index 8016c0fbff..2d424c07e4 100644 --- a/pod/completion/templates/document/form_document.html +++ b/pod/completion/templates/document/form_document.html @@ -23,7 +23,7 @@ diff --git a/pod/completion/templates/overlay/form_overlay.html b/pod/completion/templates/overlay/form_overlay.html index bf7109db38..ed98deb9b1 100644 --- a/pod/completion/templates/overlay/form_overlay.html +++ b/pod/completion/templates/overlay/form_overlay.html @@ -26,7 +26,7 @@ {% endif %} - + {% trans 'Cancel' %} diff --git a/pod/completion/templates/track/form_track.html b/pod/completion/templates/track/form_track.html index 6c5b5e35c7..4e5af42f74 100644 --- a/pod/completion/templates/track/form_track.html +++ b/pod/completion/templates/track/form_track.html @@ -25,7 +25,7 @@ {% endif %} - + {% trans 'Cancel' %} {{form_track.media}} diff --git a/pod/dressing/views.py b/pod/dressing/views.py index 32fddb152a..8338d845df 100644 --- a/pod/dressing/views.py +++ b/pod/dressing/views.py @@ -9,6 +9,7 @@ from django.views.decorators.csrf import csrf_protect from django.contrib import messages from django.core.exceptions import PermissionDenied +from django.core.handlers.wsgi import WSGIRequest from pod.video.models import Video from pod.video_encode_transcript.encode import start_encode @@ -19,7 +20,7 @@ @csrf_protect @login_required(redirect_field_name="referrer") -def video_dressing(request, slug): +def video_dressing(request: WSGIRequest, slug: str): """View for video dressing.""" if in_maintenance(): return redirect(reverse("maintenance")) @@ -34,7 +35,7 @@ def video_dressing(request, slug): dressings = get_dressings(request.user, request.user.owner.accessgroup_set.all()) - if not (request.user.is_superuser or request.user.is_staff): + if not (request.user.is_superuser or (request.user == video.owner and request.user.is_staff)): messages.add_message(request, messages.ERROR, _("You cannot dress this video.")) raise PermissionDenied @@ -74,7 +75,7 @@ def video_dressing(request, slug): @csrf_protect @login_required(redirect_field_name="referrer") -def dressing_edit(request, dressing_id): +def dressing_edit(request: WSGIRequest, dressing_id: int): """Edit a dressing object.""" dressing_edit = get_object_or_404(Dressing, id=dressing_id) @@ -115,7 +116,7 @@ def dressing_edit(request, dressing_id): @csrf_protect @login_required(redirect_field_name="referrer") -def dressing_create(request): +def dressing_create(request: WSGIRequest): """Create a dressing object.""" if not (request.user.is_superuser or request.user.is_staff): messages.add_message( @@ -144,7 +145,7 @@ def dressing_create(request): @csrf_protect @login_required(redirect_field_name="referrer") -def dressing_delete(request, dressing_id): +def dressing_delete(request: WSGIRequest, dressing_id: int): """Delete the dressing identified by 'id'.""" dressing = get_object_or_404(Dressing, id=dressing_id) if in_maintenance(): @@ -186,7 +187,7 @@ def dressing_delete(request, dressing_id): @csrf_protect @login_required(redirect_field_name="referrer") -def my_dressings(request): +def my_dressings(request: WSGIRequest): """Render the logged user's dressings.""" if in_maintenance(): return redirect(reverse("maintenance")) diff --git a/pod/live/live_transcript.py b/pod/live/live_transcript.py index e1aca90eef..75a14f44a9 100644 --- a/pod/live/live_transcript.py +++ b/pod/live/live_transcript.py @@ -89,7 +89,7 @@ def transcribe(url, slug, model, filepath): # noqa: C901 " " ), current_caption_text.split(" ") current_caption_words1 = current_caption_words[ - 1 : len(current_caption_words) + 1: len(current_caption_words) ] for i in range(len(last_caption_words) - 1, 0, -1): diff --git a/pod/locale/fr/LC_MESSAGES/django.mo b/pod/locale/fr/LC_MESSAGES/django.mo index 8e64235eba0f1a0b0d0e41534281feb74018078f..68259b0ce5ecab7e2aecae301b81193f7a35afcb 100644 GIT binary patch delta 34840 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
  • )*W#{A?*Yvq3HrMAo_3~ zM=Z+P_u+`eyPDK=C)A(2VYwTB?%U;Vy18#Ey0LbV>fR}3jh^i~cj(-{Pe`|iBj$H% z-KKN5Zmm1E-&H4GK&gPazkYS&@7kIm;B3sehc_HvnzirYrHA*;jZPjA^*{Mt=aUC4 z84xez|KG8z=cIrY&Hm?>xoMvU6#t+6uI5hz?k1S~=C6RnbC-V(NHO=$Uja#X75@;B HGX4Jnj%qK8 delta 34760 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 bp)n^f`r^iv_~c6!6ZO^?w(XtoQJVh;7fJ@C diff --git a/pod/locale/fr/LC_MESSAGES/django.po b/pod/locale/fr/LC_MESSAGES/django.po index 85b06ae4bf..1db00e94ac 100644 --- a/pod/locale/fr/LC_MESSAGES/django.po +++ b/pod/locale/fr/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-29 10:28+0000\n" +"POT-Creation-Date: 2024-05-06 12:37+0000\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -1907,7 +1907,7 @@ msgid "Cut the video" msgstr "Découper la vidéo" #: pod/cut/templates/video_cut.html pod/dressing/templates/video_dressing.html -#: pod/dressing/views.py +#: pod/dressing/tests/test_views.py pod/dressing/views.py #: pod/enrichment/templates/enrichment/group_enrichment.html #: pod/video/templates/videos/video_edit.html #: pod/video/templates/videos/video_page_content.html @@ -2224,6 +2224,14 @@ msgstr "" "Les génériques de début et de fin sont des vidéos qui s'affichent au début " "ou à la fin de la vidéo." +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "You cannot dress this video." +msgstr "Vous ne pouvez pas habiller cette vidéo." + +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "You cannot access this page." +msgstr "Vous ne pouvez pas accéder à cette page." + #: pod/dressing/tests/test_views.py pod/dressing/views.py msgid "You cannot edit this dressing." msgstr "Vous ne pouvez pas éditer cet habillage." @@ -2236,10 +2244,6 @@ msgstr "L’habillage a été supprimé." msgid "You cannot create a video dressing." msgstr "Vous ne pouvez pas créer d’habillage de vidéo." -#: pod/dressing/views.py -msgid "You cannot dress this video." -msgstr "Vous ne pouvez pas habiller cette vidéo." - #: pod/dressing/views.py #, python-format msgid "Dress the video “%s”" @@ -2268,10 +2272,6 @@ msgstr "Vous ne pouvez pas supprimer cet habillage." msgid "Deleting the dressing “%s”" msgstr "Supprimer l’habillage « %s »" -#: pod/dressing/views.py -msgid "You cannot access this page." -msgstr "Vous ne pouvez pas accéder à cette page." - #: pod/enrichment/apps.py pod/enrichment/models.py msgid "Enrichments" msgstr "Enrichissements" @@ -6194,10 +6194,6 @@ msgstr "Veuillez confirmer que vous souhaitez supprimer l’enregistrement" msgid "Delete the recording" msgstr "Supprimer l’enregistrement" -#: pod/meeting/templates/meeting/invite.html -msgid "Send invitations to the meeting" -msgstr "Envoyer des invitations à la réunion" - #: pod/meeting/templates/meeting/invite.html msgid "" "Add emails addresses in the form below and click send to invite to your " @@ -6468,6 +6464,11 @@ msgstr "Vous ne pouvez pas consulter les enregistrements de cette réunion." msgid "The recording has been deleted." msgstr "L’enregistrement a été supprimé." +#: pod/meeting/views.py +#, python-format +msgid "Send invitations to the meeting “%s”" +msgstr "Envoyer des invitations à la réunion « %s »" + #: pod/meeting/views.py msgid "You cannot invite for this meeting." msgstr "Vous ne pouvez pas inviter à cette réunion." @@ -6594,22 +6595,22 @@ msgstr "Vous ne pouvez pas redémarrer ce webinaire en direct." #: pod/meeting/webinar.py #, python-format msgid "Webinar mode has been successfully started for “%s” meeting." -msgstr "Le mode webinaire a bien été démarré pour la réunion “%s”." +msgstr "Le mode webinaire a bien été démarré pour la réunion « %s »." #: pod/meeting/webinar.py #, python-format msgid "Error to start webinar mode for “%s” meeting: %s" -msgstr "Erreur de démarrage du mode webinaire pour la réunion “%s” : %s" +msgstr "Erreur de démarrage du mode webinaire pour la réunion « %s » : %s" #: pod/meeting/webinar.py #, python-format msgid "Webinar mode has been successfully stopped for “%s” meeting." -msgstr "Le mode webinaire a bien été arrêté pour la réunion “%s”." +msgstr "Le mode webinaire a bien été arrêté pour la réunion « %s »." #: pod/meeting/webinar.py #, python-format msgid "Error to stop webinar mode for “%s” meeting: %s" -msgstr "Erreur dans l’arrêt du mode webinaire pour la réunion “%s” : %s" +msgstr "Erreur dans l’arrêt du mode webinaire pour la réunion « %s » : %s" #: pod/meeting/webinar.py msgid "" @@ -7166,6 +7167,10 @@ msgstr "Liste de lecture : %(name)s" msgid "The playlist has been deleted." msgstr "La liste de lecture a été supprimée." +#: pod/playlist/views.py +msgid "You cannot access this playlist." +msgstr "Vous ne pouvez pas accéder à cette liste de lecture." + #: pod/playlist/views.py #, python-format msgid "Edit the playlist “%(pname)s”" diff --git a/pod/locale/fr/LC_MESSAGES/djangojs.po b/pod/locale/fr/LC_MESSAGES/djangojs.po index e28819ae98..8681168444 100644 --- a/pod/locale/fr/LC_MESSAGES/djangojs.po +++ b/pod/locale/fr/LC_MESSAGES/djangojs.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Esup-Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-29 07:03+0000\n" +"POT-Creation-Date: 2024-05-06 12:38+0000\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: \n" diff --git a/pod/locale/nl/LC_MESSAGES/django.po b/pod/locale/nl/LC_MESSAGES/django.po index 07a9b75d46..786f060e16 100644 --- a/pod/locale/nl/LC_MESSAGES/django.po +++ b/pod/locale/nl/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-29 10:28+0000\n" +"POT-Creation-Date: 2024-05-06 12:37+0000\n" "PO-Revision-Date: 2024-04-15 14:27+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -1817,7 +1817,7 @@ msgid "Cut the video" msgstr "" #: pod/cut/templates/video_cut.html pod/dressing/templates/video_dressing.html -#: pod/dressing/views.py +#: pod/dressing/tests/test_views.py pod/dressing/views.py #: pod/enrichment/templates/enrichment/group_enrichment.html #: pod/video/templates/videos/video_edit.html #: pod/video/templates/videos/video_page_content.html @@ -2111,6 +2111,14 @@ msgid "" "beginning or at the end of the video." msgstr "" +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "You cannot dress this video." +msgstr "" + +#: pod/dressing/tests/test_views.py pod/dressing/views.py +msgid "You cannot access this page." +msgstr "" + #: pod/dressing/tests/test_views.py pod/dressing/views.py msgid "You cannot edit this dressing." msgstr "" @@ -2123,10 +2131,6 @@ msgstr "" msgid "You cannot create a video dressing." msgstr "" -#: pod/dressing/views.py -msgid "You cannot dress this video." -msgstr "" - #: pod/dressing/views.py #, python-format msgid "Dress the video “%s”" @@ -2155,10 +2159,6 @@ msgstr "" msgid "Deleting the dressing “%s”" msgstr "" -#: pod/dressing/views.py -msgid "You cannot access this page." -msgstr "" - #: pod/enrichment/apps.py pod/enrichment/models.py msgid "Enrichments" msgstr "" @@ -5830,10 +5830,6 @@ msgstr "" msgid "Delete the recording" msgstr "" -#: pod/meeting/templates/meeting/invite.html -msgid "Send invitations to the meeting" -msgstr "" - #: pod/meeting/templates/meeting/invite.html msgid "" "Add emails addresses in the form below and click send to invite to your " @@ -6089,6 +6085,11 @@ msgstr "" msgid "The recording has been deleted." msgstr "" +#: pod/meeting/views.py +#, python-format +msgid "Send invitations to the meeting “%s”" +msgstr "" + #: pod/meeting/views.py msgid "You cannot invite for this meeting." msgstr "" @@ -6697,6 +6698,10 @@ msgstr "" msgid "The playlist has been deleted." msgstr "" +#: pod/playlist/views.py +msgid "You cannot access this playlist." +msgstr "" + #: pod/playlist/views.py #, python-format msgid "Edit the playlist “%(pname)s”" diff --git a/pod/locale/nl/LC_MESSAGES/djangojs.po b/pod/locale/nl/LC_MESSAGES/djangojs.po index 077ed19cad..1291c7e111 100644 --- a/pod/locale/nl/LC_MESSAGES/djangojs.po +++ b/pod/locale/nl/LC_MESSAGES/djangojs.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Esup-Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-29 10:28+0000\n" +"POT-Creation-Date: 2024-05-06 12:38+0000\n" "PO-Revision-Date: 2024-04-15 14:27+0200\n" "Last-Translator: obado \n" "Language-Team: \n" @@ -163,11 +163,19 @@ msgstr "" msgid "A caption cannot contain more than 80 characters." msgstr "" +#: pod/completion/static/js/caption_maker.js +msgid "Add a caption/subtitle after this one" +msgstr "" + #: pod/completion/static/js/caption_maker.js #: pod/podfile/static/podfile/js/filewidget.js msgid "Add" msgstr "" +#: pod/completion/static/js/caption_maker.js +msgid "Delete this caption/subtitle" +msgstr "" + #: pod/completion/static/js/caption_maker.js #: pod/video/static/js/comment-script.js msgid "Delete" @@ -524,6 +532,14 @@ msgstr "" msgid "Loading…" msgstr "" +#: pod/podfile/static/podfile/js/filewidget.js +msgid "Change image" +msgstr "" + +#: pod/podfile/static/podfile/js/filewidget.js +msgid "Change file" +msgstr "" + #: pod/podfile/static/podfile/js/filewidget.js msgid "Open file in a new tab" msgstr "" @@ -645,14 +661,37 @@ msgstr "" msgid "Cancel" msgstr "" +#: pod/video/static/js/comment-script.js +#, javascript-format +msgid "%s vote" +msgid_plural "%s votes" +msgstr[0] "" +msgstr[1] "" + #: pod/video/static/js/comment-script.js msgid "Agree with the comment" msgstr "" +#: pod/video/static/js/comment-script.js +msgid "Reply to comment" +msgstr "" + +#: pod/video/static/js/comment-script.js +msgid "Reply" +msgstr "" + #: pod/video/static/js/comment-script.js msgid "Remove this comment" msgstr "" +#: pod/video/static/js/comment-script.js +msgid "Add a public comment" +msgstr "" + +#: pod/video/static/js/comment-script.js +msgid "Send" +msgstr "" + #: pod/video/static/js/comment-script.js msgid "Show answers" msgstr "" @@ -665,13 +704,6 @@ msgstr "" msgid "Sorry, you’re not allowed to vote by now." msgstr "" -#: pod/video/static/js/comment-script.js -#, javascript-format -msgid "%s vote" -msgid_plural "%s votes" -msgstr[0] "" -msgstr[1] "" - #: pod/video/static/js/comment-script.js msgid "Sorry, you can’t comment this video by now." msgstr "" @@ -710,38 +742,47 @@ msgstr[0] "" msgstr[1] "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "This content is password protected." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "This content is chaptered." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "This content is in draft." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Video content." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Audio content." msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Edit the video" msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Complete the video" msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Chapter the video" msgstr "" #: pod/video/static/js/regroup_videos_by_theme.js +#: pod/video/static/js/video_category.js msgid "Delete the video" msgstr "" @@ -813,6 +854,18 @@ msgstr "" msgid "Edit the category" msgstr "" +#: pod/video/static/js/video_category.js +msgid "Delete the category" +msgstr "" + +#: pod/video/static/js/video_category.js +msgid "Success!" +msgstr "" + +#: pod/video/static/js/video_category.js +msgid "Error…" +msgstr "" + #: pod/video/static/js/video_category.js msgid "Category created successfully" msgstr "" diff --git a/pod/main/forms.py b/pod/main/forms.py index 87ff178d51..721dd949a0 100644 --- a/pod/main/forms.py +++ b/pod/main/forms.py @@ -5,6 +5,7 @@ from django.conf import settings from captcha.fields import CaptchaField from .forms_utils import add_placeholder_and_asterisk +import os SUBJECT_CHOICES = getattr( settings, @@ -24,18 +25,26 @@ class DownloadFileForm(forms.Form): """Manage "Download File" form.""" - filename = forms.FilePathField( + filename = forms.CharField( required=True, - path=settings.MEDIA_ROOT, - recursive=True, - allow_files=True, - allow_folders=False, ) def __init__(self, *args, **kwargs): """Init download file form.""" super(DownloadFileForm, self).__init__(*args, **kwargs) + def clean(self): + """Clean "download file" form submission.""" + clean_filename = self.cleaned_data["filename"] + fullname = os.path.join(settings.MEDIA_ROOT, clean_filename) + dirname = os.path.dirname(fullname) + if not os.path.isfile(clean_filename): + raise FileNotFoundError + elif not dirname.startswith(settings.MEDIA_ROOT): + raise ValueError("File not in media directory") + else: + return self.cleaned_data + class ContactUsForm(forms.Form): """Manage "Contact us" form.""" diff --git a/pod/main/static/css/pod.css b/pod/main/static/css/pod.css index 8d6f11c58d..e4a8ce8eff 100755 --- a/pod/main/static/css/pod.css +++ b/pod/main/static/css/pod.css @@ -1519,6 +1519,7 @@ table .alert.alert-danger.btn.pod-btn-social { display: block; padding: 0.3rem 0.5rem; border-radius: 8px; + line-height: 1.3; } .vjs-logo-button { diff --git a/pod/main/tests/test_views.py b/pod/main/tests/test_views.py index 6cf59f124a..f854775d35 100644 --- a/pod/main/tests/test_views.py +++ b/pod/main/tests/test_views.py @@ -10,7 +10,7 @@ from django.utils.translation import ugettext_lazy as _ from django.contrib.flatpages.models import FlatPage from django.contrib.auth.models import User -from django.core.exceptions import PermissionDenied, SuspiciousOperation +from django.core.exceptions import PermissionDenied from django.conf import settings from captcha.models import CaptchaStore from http import HTTPStatus @@ -50,9 +50,11 @@ def test_download_file(self): # filename is not set response = self.client.post("/download/") self.assertRaises(PermissionDenied) - # filename is properly set - response = self.client.post("/download/", {"filename": "/etc/passwd"}) - self.assertRaises(SuspiciousOperation) + # Wrong filename --> not in MEDIA folder + with self.assertRaises(ValueError): + response = self.client.post("/download/", {"filename": "/etc/passwd"}) + + # Good filename filename = os.path.join(settings.MEDIA_ROOT, "test/test_file.txt") if not os.path.exists(os.path.join(settings.MEDIA_ROOT, "test")): os.mkdir(os.path.join(settings.MEDIA_ROOT, "test")) diff --git a/pod/meeting/static/css/meeting.css b/pod/meeting/static/css/meeting.css index 30fa5de52a..fb9238dcd8 100644 --- a/pod/meeting/static/css/meeting.css +++ b/pod/meeting/static/css/meeting.css @@ -64,14 +64,6 @@ } /* Message error */ -div.alert.alert-dismissible { - border-radius: 6px; - display: table; - width: 100%; - padding-left: 78px; - position: relative; - padding-right: 60px; -} div.alert .icon { text-align: center; @@ -138,3 +130,7 @@ div.alert .proposition::before { .meeting-nowrap { white-space: nowrap !important; } + +.meeting-header-card { + min-height: 4rem; +} diff --git a/pod/meeting/static/js/my_meetings.js b/pod/meeting/static/js/my_meetings.js index dc8543d273..5ee3053082 100644 --- a/pod/meeting/static/js/my_meetings.js +++ b/pod/meeting/static/js/my_meetings.js @@ -126,7 +126,7 @@ function copyValue(value) { navigator.clipboard .writeText(value) .then(() => { - showalert(gettext("Text copied."), "alert-success"); + showalert(gettext("Text copied."), "alert-info"); }) .catch(() => { showalert(gettext("Something went wrong."), "alert-danger"); diff --git a/pod/meeting/templates/meeting/invite.html b/pod/meeting/templates/meeting/invite.html index 72ad88818b..45c5165e8e 100644 --- a/pod/meeting/templates/meeting/invite.html +++ b/pod/meeting/templates/meeting/invite.html @@ -4,51 +4,45 @@ {% load thumbnail %} {% block breadcrumbs %} -{{ block.super }} -
  • - -{% endblock %} - -{% block page_title %} -{% trans 'Send invitations to the meeting' %} {{meeting.name}} + {{ block.super }} + + {% endblock %} {% block page_content %} -

    {% trans 'Send invitations to the meeting' %} {{meeting.name}}

    - -
    - {% csrf_token %} -
    -
    - {% trans 'Add emails addresses in the form below and click send to invite to your meeting' %} - {% if form.errors %} -

    {% trans "One or more errors have been found in the form." %}

    - {% endif %} - {% for field_hidden in form.hidden_fields %} - {{field_hidden}} - {% endfor %} - {% for field in form.visible_fields %} - {% spaceless %} -
    -
    - {{ field.errors }} - - {{ field }} - {% if field.help_text %} - {{ field.help_text|safe }} - {% endif %} - {% if field.field.required %}
    {% trans "Please provide a valid value for this field." %}
    {% endif %} -
    + + {% csrf_token %} +
    +
    + {% trans 'Add emails addresses in the form below and click send to invite to your meeting' %} + {% if form.errors %} +

    {% trans "One or more errors have been found in the form." %}

    + {% endif %} + {% for field_hidden in form.hidden_fields %} + {{ field_hidden }} + {% endfor %} + {% for field in form.visible_fields %} + {% spaceless %} +
    +
    + {{ field.errors }} + + {{ field }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} + {% if field.field.required %}
    {% trans "Please provide a valid value for this field." %}
    {% endif %} +
    +
    + {% endspaceless %} + {% endfor %} +
    +
    - {% endspaceless %} - {% endfor %} -
    - -
    -
    -
    - +
    +
    + {% endblock page_content %} diff --git a/pod/meeting/templates/meeting/meeting_card.html b/pod/meeting/templates/meeting/meeting_card.html index 44a5ccee00..636375995f 100644 --- a/pod/meeting/templates/meeting/meeting_card.html +++ b/pod/meeting/templates/meeting/meeting_card.html @@ -3,7 +3,7 @@
    -
    +
    {{meeting.name|capfirst|truncatechars:43}}
    {% if meeting.is_personal %} @@ -80,10 +80,10 @@ data-bs-meeting-end-url="{% url 'meeting:end' meeting.meeting_id %}" data-bs-meeting-info-url="{% url 'meeting:get_meeting_info' meeting.meeting_id %}" data-bs-meeting-webinar="{{meeting.is_webinar}}" aria-label="{% trans 'Show meeting informations' %}"> - + {% trans "Join the meeting" %} - + {% endif %}
    {% else %} diff --git a/pod/meeting/views.py b/pod/meeting/views.py index 1bbaeed5b4..6bac160e72 100644 --- a/pod/meeting/views.py +++ b/pod/meeting/views.py @@ -947,6 +947,7 @@ def invite(request: WSGIRequest, meeting_id: str) -> HttpResponse: meeting = get_object_or_404( Meeting, meeting_id=meeting_id, site=get_current_site(request) ) + page_title = _("Send invitations to the meeting “%s”") % meeting.name if ( request.user != meeting.owner @@ -970,13 +971,17 @@ def invite(request: WSGIRequest, meeting_id: str) -> HttpResponse: emails = get_dest_emails(meeting, form) send_invite(request, meeting, emails) display_message_with_icon( - request, messages.INFO, _("Invitations send to recipients.") + request, messages.SUCCESS, _("Invitations send to recipients.") ) return redirect(reverse("meeting:my_meetings")) return render( request, "meeting/invite.html", - {"meeting": meeting, "form": form}, + { + "page_title": page_title, + "meeting": meeting, + "form": form + }, ) diff --git a/pod/playlist/models.py b/pod/playlist/models.py index 87a0a7085a..c86fa6863a 100644 --- a/pod/playlist/models.py +++ b/pod/playlist/models.py @@ -164,7 +164,7 @@ def get_first_video(self, request=None) -> Video: if request is not None: for video in sort_videos_list(get_video_list_for_playlist(self), "rank"): - if user_can_see_playlist_video(request, video): + if user_can_see_playlist_video(request, video, self): return video return sort_videos_list(get_video_list_for_playlist(self), "rank").first() diff --git a/pod/playlist/templates/playlist/playlist_player.html b/pod/playlist/templates/playlist/playlist_player.html index b316b042a7..ee326b8582 100644 --- a/pod/playlist/templates/playlist/playlist_player.html +++ b/pod/playlist/templates/playlist/playlist_player.html @@ -33,7 +33,7 @@

    5 %}class="scroll-container"{% endif %}> {% for video_in_playlist in videos %} - {% can_see_playlist_video video_in_playlist as can_see_video %} + {% can_see_playlist_video video_in_playlist playlist as can_see_video %} @register.simple_tag(takes_context=True, name="can_see_playlist_video") -def can_see_playlist_video(context: dict, video: Video) -> bool: +def can_see_playlist_video(context: dict, video: Video, playlist: Playlist) -> bool: """ Template tag to check if the user can see a playlist video. @@ -65,4 +65,4 @@ def can_see_playlist_video(context: dict, video: Video) -> bool: Returns: bool: `True` if the user can, `False` otherwise """ - return user_can_see_playlist_video(context["request"], video) + return user_can_see_playlist_video(context["request"], video, playlist) diff --git a/pod/playlist/tests/test_views.py b/pod/playlist/tests/test_views.py index 317fd5fa40..698c6bfc45 100644 --- a/pod/playlist/tests/test_views.py +++ b/pod/playlist/tests/test_views.py @@ -957,7 +957,9 @@ def test_user_redirect_if_private_playlist_playlists(self) -> None: importlib.reload(context_processors) self.client.force_login(self.first_student) response = self.client.get(self.url_private_playlist) - self.assertEqual(response.status_code, 302) + messages = [m.message for m in get_messages(response.wsgi_request)] + self.assertIn(_("You cannot access this playlist."), messages) + self.assertEqual(response.status_code, 403) self.client.logout() print(" ---> test_user_redirect_if_private_playlist_playlists ok") @@ -1208,23 +1210,6 @@ def test_start_playlist_public(self): self.client.logout() print(" ---> test_start_playlist_public ok") - def test_start_playlist_private_owner(self): - """Test if start a private playlist when owner of it works.""" - importlib.reload(context_processors) - self.client.force_login(self.user) - response = self.client.get( - reverse( - "playlist:start-playlist", - kwargs={"slug": self.private_playlist_user1.slug}, - ) - ) - expected_url = get_link_to_start_playlist( - self.client.request, self.private_playlist_user1 - ) - self.assertRedirects(response, expected_url) - self.client.logout() - print(" ---> test_start_playlist_private_owner ok") - def test_start_playlist_private_not_owner(self): """Test if start a private playlist don't works if not owner of it.""" importlib.reload(context_processors) diff --git a/pod/playlist/utils.py b/pod/playlist/utils.py index e8304a5cd5..f9c5ad27a0 100644 --- a/pod/playlist/utils.py +++ b/pod/playlist/utils.py @@ -316,13 +316,14 @@ def get_count_video_added_in_playlist(video: Video) -> int: return PlaylistContent.objects.filter(video=video).count() -def user_can_see_playlist_video(request: WSGIRequest, video: Video) -> bool: +def user_can_see_playlist_video(request: WSGIRequest, video: Video, playlist: Playlist) -> bool: """ Check if the authenticated can see the playlist video. Args: request (WSGIRequest): The WSGIRequest video (:class:`pod.video.models.Video`): The video object + playlist (:class:`pod.playlist.models.Playlist`): The playlist object Returns: bool: True if the user can see the playlist video. False otherwise @@ -332,12 +333,16 @@ def user_can_see_playlist_video(request: WSGIRequest, video: Video) -> bool: if not request.user.is_authenticated: return False return ( - (video.owner == request.user) - or (request.user in video.additional_owners.all()) - or (request.user.is_staff) + video.owner == request.user + or request.user in video.additional_owners.all() + or request.user.is_superuser ) else: - return True + return playlist.visibility == "private" and ( + playlist.owner == request.user + or playlist in get_playlists_for_additional_owner(request.user) + or request.user.is_superuser + ) or playlist.visibility in {"public", "protected"} def sort_playlist_list(playlist_list: list, sort_field: str, sort_direction="") -> list: diff --git a/pod/playlist/views.py b/pod/playlist/views.py index 08917fab87..26ab1f4b53 100644 --- a/pod/playlist/views.py +++ b/pod/playlist/views.py @@ -1,7 +1,10 @@ +"""Esup-Pod playlist views.""" + from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import PermissionDenied from django.core.handlers.wsgi import WSGIRequest from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.http import HttpResponseRedirect @@ -41,6 +44,7 @@ import json import hashlib +from typing import List TEMPLATE_VISIBLE_SETTINGS = getattr( @@ -74,10 +78,10 @@ USE_PROMOTED_PLAYLIST = getattr(settings, "USE_PROMOTED_PLAYLIST", True) -def playlist_list(request): +def playlist_list(request: WSGIRequest): """Render playlists page.""" visibility = request.GET.get("visibility", "all") - if visibility in ["private", "protected", "public"] and request.user.is_authenticated: + if visibility in {"private", "protected", "public"} and request.user.is_authenticated: playlists = get_playlist_list_for_user(request.user).filter(visibility=visibility) elif visibility == "additional" and request.user.is_authenticated: playlists = get_playlists_for_additional_owner(request.user) @@ -115,33 +119,37 @@ def playlist_list(request): ) -def playlist_content(request, slug): +def playlist_content(request: WSGIRequest, slug: str): """Render the videos list of a playlist.""" sort_field = request.GET.get("sort", "rank") sort_direction = request.GET.get("sort_direction") - playlist = get_playlist(slug) + playlist = get_object_or_404(Playlist, slug=slug) if ( playlist.visibility == "public" or playlist.visibility == "protected" or ( - playlist.owner == request.user + request.user.is_authenticated and + (playlist.owner == request.user or playlist in get_playlists_for_additional_owner(request.user) - or request.user.is_staff + or request.user.is_superuser) ) ): return render_playlist(request, playlist, sort_field, sort_direction) else: - return HttpResponseRedirect(reverse("playlist:list")) + messages.add_message( + request, messages.ERROR, _("You cannot access this playlist.") + ) + raise PermissionDenied def render_playlist_page( - request, - playlist, - videos, - in_favorites_playlist, - count_videos, - sort_field, - sort_direction=None, + request: WSGIRequest, + playlist: Playlist, + videos: List[Video], + in_favorites_playlist: bool, + count_videos: int, + sort_field: str, + sort_direction: str = None, form=None, ): """Render playlist page with the videos list of this.""" @@ -185,13 +193,13 @@ def render_playlist_page( def toggle_render_playlist_user_has_right( - request, - playlist, - videos, - in_favorites_playlist, - count_videos, - sort_field, - sort_direction, + request: WSGIRequest, + playlist: Playlist, + videos: List[Video], + in_favorites_playlist: bool, + count_videos: int, + sort_field: str, + sort_direction: str, ): """Toggle render_playlist() when the user has right.""" if request.method == "POST": @@ -228,7 +236,7 @@ def toggle_render_playlist_user_has_right( def render_playlist( - request: dict, playlist: Playlist, sort_field: str, sort_direction: str + request: WSGIRequest, playlist: Playlist, sort_field: str, sort_direction: str ): """Render playlist page with the videos list of this.""" videos_list = sort_videos_list( @@ -325,10 +333,10 @@ def render_playlist__authenticated_user( @login_required(redirect_field_name="referrer") -def remove_video_in_playlist(request, slug, video_slug): +def remove_video_in_playlist(request: WSGIRequest, slug: str, video_slug: str): """Remove a video in playlist.""" playlist = get_object_or_404(Playlist, slug=slug) - video = Video.objects.get(slug=video_slug) + video = get_object_or_404(Video, slug=video_slug) user_remove_video_from_playlist(playlist, video) if request.GET.get("json"): return JsonResponse( @@ -340,10 +348,10 @@ def remove_video_in_playlist(request, slug, video_slug): @login_required(redirect_field_name="referrer") -def add_video_in_playlist(request, slug, video_slug): +def add_video_in_playlist(request: WSGIRequest, slug: str, video_slug: str): """Add a video in playlist.""" - playlist = get_playlist(slug) - video = Video.objects.get(slug=video_slug) + playlist = get_object_or_404(Playlist, slug=slug) + video = get_object_or_404(Video, slug=video_slug) user_add_video_in_playlist(playlist, video) if request.GET.get("json"): return JsonResponse( @@ -355,7 +363,7 @@ def add_video_in_playlist(request, slug, video_slug): @login_required(redirect_field_name="referrer") -def remove_playlist_view(request, slug: str): +def remove_playlist_view(request: WSGIRequest, slug: str): """Remove playlist with form.""" playlist = get_object_or_404(Playlist, slug=slug) if in_maintenance(): @@ -390,7 +398,9 @@ def remove_playlist_view(request, slug: str): @login_required(redirect_field_name="referrer") -def handle_post_request_for_add_or_edit_function(request, playlist: Playlist) -> None: +def handle_post_request_for_add_or_edit_function( + request: WSGIRequest, playlist: Playlist +) -> None: """Handle post request for add_or_edit function.""" page_title = "" form = ( @@ -448,7 +458,7 @@ def handle_post_request_for_add_or_edit_function(request, playlist: Playlist) -> @login_required(redirect_field_name="referrer") -def handle_get_request_for_add_or_edit_function(request, slug: str) -> None: +def handle_get_request_for_add_or_edit_function(request: WSGIRequest, slug: str) -> None: """Handle get request for add_or_edit function.""" if request.GET.get("next"): options = f"?next={request.GET.get('next')}" @@ -482,7 +492,7 @@ def handle_get_request_for_add_or_edit_function(request, slug: str) -> None: @csrf_protect @ensure_csrf_cookie @login_required(redirect_field_name="referrer") -def add_or_edit(request, slug: str = None): +def add_or_edit(request: WSGIRequest, slug: str = None): """Add or edit view with form.""" playlist = get_object_or_404(Playlist, slug=slug) if slug else None if in_maintenance(): @@ -495,7 +505,7 @@ def add_or_edit(request, slug: str = None): @csrf_protect @login_required(redirect_field_name="referrer") -def favorites_save_reorganisation(request, slug: str): +def favorites_save_reorganisation(request: WSGIRequest, slug: str): """Save reorganization when the user click on save button.""" if request.method == "POST": json_data = request.POST.get("json-data") @@ -525,7 +535,7 @@ def favorites_save_reorganisation(request, slug: str): raise Http404() -def start_playlist(request, slug, video=None): +def start_playlist(request: WSGIRequest, slug: str, video: Video = None): playlist = get_object_or_404(Playlist, slug=slug) if ( diff --git a/pod/recorder/plugins/type_studio.py b/pod/recorder/plugins/type_studio.py index b0e1d4b927..72788a5dc4 100644 --- a/pod/recorder/plugins/type_studio.py +++ b/pod/recorder/plugins/type_studio.py @@ -206,7 +206,7 @@ def getElementsByName(xmldoc, name): urlElement = element.getElementsByTagName("url")[0] if urlElement.firstChild and urlElement.firstChild.data != "": element_path = urlElement.firstChild.data[ - urlElement.firstChild.data.index(MEDIA_URL) + len(MEDIA_URL) : + urlElement.firstChild.data.index(MEDIA_URL) + len(MEDIA_URL): ] src = os.path.join(settings.MEDIA_ROOT, element_path) if os.path.isfile(src): diff --git a/pod/recorder/views.py b/pod/recorder/views.py index 174621b7b4..d0b07b1103 100644 --- a/pod/recorder/views.py +++ b/pod/recorder/views.py @@ -381,13 +381,13 @@ def studio_pod(request): ) head = opencast_studio_rendered[ opencast_studio_rendered.index("") - + len("") : opencast_studio_rendered.index("") + + len(""): opencast_studio_rendered.index("") ] scripts = re.findall('', head) styles = re.findall("", head) body = opencast_studio_rendered[ opencast_studio_rendered.index("") - + len("") : opencast_studio_rendered.index("") + + len(""): opencast_studio_rendered.index("") ] body = "".join(scripts) + "".join(styles) + body return render( diff --git a/pod/settings.py b/pod/settings.py index 1cfc591dc2..3810e3f9a5 100644 --- a/pod/settings.py +++ b/pod/settings.py @@ -13,7 +13,7 @@ ## # Version of the project # -VERSION = "3.6.0" +VERSION = "3.6.1" ## # Installed applications list diff --git a/pod/video/management/commands/import_encoded_video.py b/pod/video/management/commands/import_encoded_video.py deleted file mode 100755 index a76e0e8f7e..0000000000 --- a/pod/video/management/commands/import_encoded_video.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Import recorded video into Pod.""" - -from django.conf import settings - -from django.utils import translation -from django.core.management.base import BaseCommand -from django.core.exceptions import ObjectDoesNotExist - -from pod.video.models import Video -from pod.video.remote_encode import store_remote_encoding_video - - -LANGUAGE_CODE = getattr(settings, "LANGUAGE_CODE", "fr") - - -class Command(BaseCommand): - # args = 'video_id' - help = "Import recorded video into Pod" - - def add_arguments(self, parser): - parser.add_argument( - "video_id", - type=int, - help="Indicates the id of the video to import encoded files", - ) - - def handle(self, *args, **options): - # Activate a fixed locale fr - translation.activate(LANGUAGE_CODE) - video_id = options["video_id"] - try: - video = Video.objects.get(id=video_id) - store_remote_encoding_video(video_id) - self.stdout.write( - self.style.SUCCESS('Successfully import encoded video "%s"' % (video)) - ) - except ObjectDoesNotExist: - self.stdout.write( - self.style.ERROR( - "******* Video id matching query does not exist: %s *******" - % video_id - ) - ) diff --git a/pod/video/static/js/dashboard.js b/pod/video/static/js/dashboard.js index a7af4058d1..f45849ac14 100644 --- a/pod/video/static/js/dashboard.js +++ b/pod/video/static/js/dashboard.js @@ -141,6 +141,14 @@ async function bulkUpdate() { }); } + // Remove unecessaries fields. + if (updateFields.includes("channel") && updateFields.includes("theme")) { + if (formData.get("channel") != "" && formData.get("theme") == "") { + updateFields.pop("theme"); + formData.delete("theme"); + } + } + // Construct formData to send formData.append("selected_videos", JSON.stringify(selectedVideos)); formData.append("update_fields", JSON.stringify(updateFields)); diff --git a/pod/video/templates/videos/card.html b/pod/video/templates/videos/card.html index 0e1c51c601..e9fbce283e 100644 --- a/pod/video/templates/videos/card.html +++ b/pod/video/templates/videos/card.html @@ -4,7 +4,7 @@ {% spaceless %} {% if playlist %} {% load playlist_buttons %} - {% can_see_playlist_video video as can_see_video %} + {% can_see_playlist_video video playlist as can_see_video %} {% endif %}
    diff --git a/pod/video/templates/videos/link_video.html b/pod/video/templates/videos/link_video.html index e84b5a8457..608793f456 100644 --- a/pod/video/templates/videos/link_video.html +++ b/pod/video/templates/videos/link_video.html @@ -63,7 +63,7 @@ {% endif %} {% if USE_DRESSING and video.encoded and video.encoding_in_progress is False %} - {% if request.user.is_superuser or request.user.is_staff %} + {% if request.user.is_staff and video.owner == request.user or request.user.is_superuser %} diff --git a/pod/video/templates/videos/video-iframe.html b/pod/video/templates/videos/video-iframe.html index f1e74453c0..a622ba5d84 100644 --- a/pod/video/templates/videos/video-iframe.html +++ b/pod/video/templates/videos/video-iframe.html @@ -37,7 +37,7 @@ - + {% block page_content %}
    diff --git a/pod/video/templates/videos/video-info.html b/pod/video/templates/videos/video-info.html index 38ae4b9111..f38d30a0ab 100644 --- a/pod/video/templates/videos/video-info.html +++ b/pod/video/templates/videos/video-info.html @@ -281,9 +281,9 @@

    {% endif %} {% if video.document_set.all %} - +

     {% trans 'Document:' %} - +

      {% for doc in video.document_set.all %} {% if request.user.is_superuser or not doc.private %} diff --git a/pod/video/templates/videos/video_row_select.html b/pod/video/templates/videos/video_row_select.html index f43131777f..36feea53c2 100644 --- a/pod/video/templates/videos/video_row_select.html +++ b/pod/video/templates/videos/video_row_select.html @@ -6,7 +6,7 @@ {% spaceless %} {% if playlist %} {% load playlist_buttons %} - {% can_see_playlist_video video as can_see_video %} + {% can_see_playlist_video video playlist as can_see_video %} {% endif %}
      diff --git a/pod/video/utils.py b/pod/video/utils.py index c019ccde7e..270105b6bf 100644 --- a/pod/video/utils.py +++ b/pod/video/utils.py @@ -180,7 +180,7 @@ def get_videos(title, user_id, search=None, limit=12, offset=0): results = list( map( lambda v: {"id": v.id, "title": v.title, "thumbnail": v.get_thumbnail_url()}, - videos[offset : limit + offset], + videos[offset: limit + offset], ) ) diff --git a/pod/video/views.py b/pod/video/views.py index 1cf45ee8ae..b530781b5f 100644 --- a/pod/video/views.py +++ b/pod/video/views.py @@ -253,7 +253,7 @@ def _regroup_videos_by_theme(request, videos, channel, theme=None): request.path, offset, limit, videos.count() ) count = videos.count() - videos = videos[offset : limit + offset] + videos = videos[offset: limit + offset] response = { **response, "videos": list(videos), @@ -1084,7 +1084,7 @@ def video(request, slug, slug_c=None, slug_t=None, slug_private=None): template_video = "videos/video-iframe.html" elif request.GET.get("playlist"): playlist = get_object_or_404(Playlist, slug=request.GET.get("playlist")) - if playlist_can_be_displayed(request, playlist): + if playlist_can_be_displayed(request, playlist) and user_can_see_playlist_video(request, video, playlist): videos = sort_videos_list(get_video_list_for_playlist(playlist), "rank") params = { "playlist_in_get": playlist, @@ -1190,10 +1190,13 @@ def render_video( if toggle_render_video_user_can_see_video( show_page, is_password_protected, request, slug_private, video ): - if request.GET.get("playlist") and not user_can_see_playlist_video( - request, video - ): - toggle_render_video_when_is_playlist_player(request) + playlist = None + if request.GET.get("playlist"): + playlist = get_object_or_404(Playlist, slug=request.GET.get("playlist")) + if not user_can_see_playlist_video( + request, video, playlist, + ): + toggle_render_video_when_is_playlist_player(request) return render( request, template_video, @@ -1203,6 +1206,7 @@ def render_video( "theme": theme, "listNotes": listNotes, "owner_filter": owner_filter, + "playlist": playlist if request.GET.get("playlist") else None, **more_data, }, ) diff --git a/pod/video_encode_transcript/Encoding_video_model.py b/pod/video_encode_transcript/Encoding_video_model.py index c4427ba347..7781c4995a 100644 --- a/pod/video_encode_transcript/Encoding_video_model.py +++ b/pod/video_encode_transcript/Encoding_video_model.py @@ -21,7 +21,7 @@ ) from pod.video.models import LANG_CHOICES import json - +import time from .encoding_utils import ( launch_cmd, check_file, @@ -192,7 +192,7 @@ def store_json_encoding_log(self, info_video, video_to_encode): # Need to modify start and stop log_to_text = "" # logs = info_video["encoding_log"] - log_to_text = log_to_text + "Start: " + self.start + log_to_text += "Start: %s " % str(self.start) """ for log in logs: log_to_text = log_to_text + "[" + log + "]\n\n" @@ -208,9 +208,9 @@ def store_json_encoding_log(self, info_video, video_to_encode): ) """ # add path to log file to easily open it - log_to_text = log_to_text + "\nLog File: \n" - log_to_text = log_to_text + self.get_output_dir() + "/info_video.json" - log_to_text = log_to_text + "\nEnd: " + self.stop + log_to_text += "\nLog File: \n" + log_to_text += self.get_output_dir() + "/info_video.json" + log_to_text += "\nEnd: %s" % str(self.stop) encoding_log, created = EncodingLog.objects.get_or_create(video=video_to_encode) encoding_log.log = log_to_text @@ -298,9 +298,24 @@ def store_json_list_overview_files(self, info_video) -> Video: video.save() return video + def wait_for_file(self, filepath): + time_to_wait = 40 + time_counter = 0 + while not os.path.exists(filepath): + time.sleep(1) + time_counter += 1 + print("wait...") + if time_counter > time_to_wait: + break + print("infovideojsonfilepath : %s" % filepath) + def store_json_info(self) -> Video: """Open json file and store its data in current instance.""" - with open(self.get_output_dir() + "/info_video.json") as json_file: + infovideojsonfilepath = os.path.join(self.get_output_dir(), "info_video.json") + if not check_file(infovideojsonfilepath): + self.wait_for_file(infovideojsonfilepath) + + with open(infovideojsonfilepath, "r") as json_file: info_video = json.load(json_file) video_to_encode = Video.objects.get(id=self.id) video_to_encode.duration = info_video["duration"] diff --git a/pod/video_encode_transcript/management/commands/import_encode_video.py b/pod/video_encode_transcript/management/commands/import_encode_video.py new file mode 100644 index 0000000000..97cc9b0cfa --- /dev/null +++ b/pod/video_encode_transcript/management/commands/import_encode_video.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +from pod.video_encode_transcript.Encoding_video_model import Encoding_video_model +from pod.video_encode_transcript.encode import store_encoding_info, end_of_encoding +from pod.video.models import Video + + +class Command(BaseCommand): + help = "Import encoded video" + + def add_arguments(self, parser): + parser.add_argument("video_id", type=int) + + def handle(self, *args, **options): + video_id = options["video_id"] + vid = Video.objects.get(id=video_id) + encoding_video = Encoding_video_model(video_id, vid.video.path) + final_video = store_encoding_info(video_id, encoding_video) + end_of_encoding(final_video) + self.stdout.write(self.style.SUCCESS('Successfully import video "%s"' % video_id)) diff --git a/pod/video_encode_transcript/transcript_model.py b/pod/video_encode_transcript/transcript_model.py index f21e46a8d6..2043b20523 100644 --- a/pod/video_encode_transcript/transcript_model.py +++ b/pod/video_encode_transcript/transcript_model.py @@ -464,7 +464,7 @@ def get_text_caption(text_caption, last_word_added): """Get the text for a caption.""" try: first_index = text_caption.index(last_word_added) - return text_caption[first_index + 1 :] + return text_caption[first_index + 1:] except ValueError: return text_caption diff --git a/setup.cfg b/setup.cfg index 61f206648d..1f62524fbf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,15 @@ [flake8] -exclude = .git,pod/*/migrations/*.py,test_settings.py,node_modules/*/*.py +exclude = .git,pod/*/migrations/*.py,test_settings.py,node_modules/*/*.py,pod/static/*.py,pod/custom/tenants/*/*.py max-complexity = 7 max-line-length = 90 +# See https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8 ignore = + # E501: line too long (will be auto corrected by Black) + E501, + # E203: Whitespace before ':' + # cf Black Readme: "E203 is not PEP 8 compliant" + E203, + # W503: line break before binary operator W503,