diff --git a/.env.dev-exemple b/.env.dev-exemple index 2633342672..1d9d568b66 100644 --- a/.env.dev-exemple +++ b/.env.dev-exemple @@ -1,7 +1,12 @@ DJANGO_SUPERUSER_USERNAME= DJANGO_SUPERUSER_PASSWORD= DJANGO_SUPERUSER_EMAIL= -ELASTICSEARCH_TAG=elasticsearch:7.17.7 +### You can use internal registry +ELASTICSEARCH_TAG=elasticsearch:8.8.1 NODE_TAG=node:19 -PYTHON_TAG=python:3.7-buster -REDIS_TAG=redis:alpine3.16 \ No newline at end of file +PYTHON_TAG=python:3.9-buster +REDIS_TAG=redis:alpine3.16 +### DOCKER_ENV : You can specify light or full. +### In case of value changing, you have to rebuild and restart your container. +### All yours datas will be kept. +DOCKER_ENV=light \ No newline at end of file diff --git a/.github/workflows/pod.yml b/.github/workflows/pod.yml index 9b2346ef22..024cafe9f5 100644 --- a/.github/workflows/pod.yml +++ b/.github/workflows/pod.yml @@ -10,6 +10,7 @@ on: jobs: build: runs-on: ubuntu-latest + strategy: max-parallel: 4 matrix: diff --git a/AUTHORS.md b/AUTHORS.md index f01b1b1af6..6c10eacecb 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -37,3 +37,4 @@ Pictures credits ---------------------------- * default.svg: adapted from Play button Icon by [Freepik](https://www.freepik.com/free-vector) - Freepik License * cookie.svg: [Broken oatmeal cookie created by pch.vector](https://www.freepik.com/vectors/logo) - Freepik License +* default-playlist.svg: Music, Note, Musical Note by [krzysztof-m](https://pixabay.com/fr/users/1363864/) - [Pixabay free for use & download licence](https://pixabay.com/fr/service/terms/) diff --git a/CONFIGURATION_FR.md b/CONFIGURATION_FR.md deleted file mode 100644 index 59ec126459..0000000000 --- a/CONFIGURATION_FR.md +++ /dev/null @@ -1,2668 +0,0 @@ - -# Configuration de la plateforme Esup-Pod - - -## Information générale - - -La plateforme Esup-Pod se base sur le framework Django écrit en Python.
-Elle supporte les versions 3.7, 3.8 et 3.9 de Python.
- -**Django Version : 3.2 LTS**
- -> La documentation complète du framework : [https://docs.djangoproject.com/fr/3.2/]() (ou [https://docs.djangoproject.com/en/3.2/]())

-> L’ensemble des variables de configuration du framework est accessible à cette adresse : [https://docs.djangoproject.com/fr/3.2/ref/settings/]()
- -Voici les configurations des applications tierces utilisées par Esup-Pod.
- - - - `CAS` - - > valeur par défaut : `1.5.2` - - >> Système d’authentification SSO_CAS
- >> [https://github.com/kstateome/django-cas]()
- - - `ModelTransalation` - - > valeur par défaut : `0.18.7` - - >> L’application modeltranslation est utilisée pour traduire le contenu dynamique des modèles Django existants
- >> [https://django-modeltranslation.readthedocs.io/en/latest/installation.html#configuration]()
- - - `captcha` - - > valeur par défaut : `0.5.17` - - >> Gestion du captcha du formulaire de contact
- >> [https://django-simple-captcha.readthedocs.io/en/latest/usage.html]()
- - - `chunked_upload` - - > valeur par défaut : `2.0.0` - - >> Envoi de fichier par morceaux // voir pour mettre à jour si nécessaire
- >> [https://github.com/juliomalegria/django-chunked-upload]()
- - - `ckeditor` - - > valeur par défaut : `6.3.0` - - >> Application permettant d’ajouter un éditeur CKEditor dans certains champs
- >> [https://django-ckeditor.readthedocs.io/en/latest/#installation]()
- - - `django_select2` - - > valeur par défaut : `latest` - - >> Recherche et completion dans les formulaires
- >> [https://django-select2.readthedocs.io/en/latest/]()
- - - `honeypot` - - > valeur par défaut : `1.0.3` - - >> Utilisé pour le formulaire de contact de Pod - ajoute un champ caché pour diminuer le spam
- >> [https://github.com/jamesturk/django-honeypot/]()
- - - `mozilla_django_oidc` - - > valeur par défaut : `3.0.0` - - >> Système d’authentification OpenID Connect
- >> [https://mozilla-django-oidc.readthedocs.io/en/stable/installation.html]()
- - - `rest_framework` - - > valeur par défaut : `3.14.0` - - >> version 3.14.0: mise en place de l’API rest pour l’application
- >> [https://www.django-rest-framework.org/]()
- - - `shibboleth` - - > valeur par défaut : `latest` - - >> Système d’authentification Shibboleth
- >> [https://github.com/Brown-University-Library/django-shibboleth-remoteuser]()
- - - `sorl.thumbnail` - - > valeur par défaut : `12.9.0` - - >> Utilisée pour la génération de miniature des images
- >> [https://sorl-thumbnail.readthedocs.io/en/latest/reference/settings.html]()
- - - `tagging` - - > valeur par défaut : `0.5.0` - - >> Gestion des mots-clés associés à une vidéo // voir pour référencer une nouvelle application
- >> [https://django-tagging.readthedocs.io/en/develop/#settings]()
- -## Configuration Générale de la plateforme Esup_Pod - - -### Base de données - - - `DATABASES` - - > valeur par défaut : - - ```python - { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } - } - ``` - - >> Un dictionnaire contenant les réglages de toutes les bases de données à utiliser avec Django.
- >> C’est un dictionnaire imbriqué dont les contenus font correspondre l’alias de base de données avec un dictionnaire contenant les options de chacune des bases de données.
- >> __ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#databases]()__
- >> valeur par défaut : une base de données au format sqlite
- >> Voici un exemple de configuration pour utiliser une base MySQL :
- >> - >> ``` - >> DATABASES = { - >> 'default': { - >> 'ENGINE': 'django.db.backends.mysql', - >> 'NAME': 'pod', - >> 'USER': 'pod', - >> 'PASSWORD': 'password', - >> 'HOST': 'mysql.univ.fr', - >> 'PORT': '3306', - >> 'OPTIONS': { - >> 'init_command': "SET storage_engine=INNODB, sql_mode='STRICT_TRANS_TABLES', innodb_strict_mode=1, foreign_key_checks = 1", - >> }, - >> } - >> } - >> - >> ``` - -### Courriel - - - `CONTACT_US_EMAIL` - - > valeur par défaut : `` - - >> Liste des adresses destinataires des courriels de contact
- - - `CUSTOM_CONTACT_US` - - > valeur par défaut : `False` - - >> Si 'True', les e-mails de contacts seront adressés, selon le sujet,
- >> soit au propriétaire de la vidéo soit au(x) manager(s) des vidéos Pod.
- >> (voir `USER_CONTACT_EMAIL_CASE` et `USE_ESTABLISHMENT_FIELD`)
- - - `DEFAULT_FROM_EMAIL` - - > valeur par défaut : `noreply` - - >> Expediteur par défaut pour les envois de courriel (contact, encodage etc.)
- - - `EMAIL_HOST` - - > valeur par défaut : `smtp.univ.fr` - - >> nom du serveur smtp
- >> _ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#email-host]()_
- - - `EMAIL_PORT` - - > valeur par défaut : `25` - - >> Port d’écoute du serveur SMTP.
- - - `EMAIL_SUBJECT_PREFIX` - - > valeur par défaut : `` - - >> Préfixe par défaut pour l’objet des courriels.
- - - `SERVER_EMAIL` - - > valeur par défaut : `noreply` - - >> Expediteur par défaut pour les envois automatique (erreur de code etc.)
- - - `SUBJECT_CHOICES` - - > valeur par défaut : `()` - - >> Choix de sujet pour les courriels envoyés depuis la plateforme
- >> - >> ``` - >> SUBJECT_CHOICES = ( - >> ('', '-----'), - >> ('info', ('Request more information')), - >> ('contribute', ('Learn more about how to contribute')), - >> ('request_password', ('Password request for a video')), - >> ('inappropriate_content', ('Report inappropriate content')), - >> ('bug', ('Correction or bug report')), - >> ('other', ('Other (please specify)')) - >> ) - >> - >> ``` - - - `SUPPORT_EMAIL` - - > valeur par défaut : `None` - - >> Liste de destinataire(s) pour les demandes d’assistance, si différent de `CONTACT_US_EMAIL`
- >> i.e.: `SUPPORT_EMAIL = ["assistance_pod@univ.fr"]`
- - - `USER_CONTACT_EMAIL_CASE` - - > valeur par défaut : `` - - >> Une liste contenant les sujets de contact dont l’utilisateur
- >> sera seul destinataire plutôt que le(s) manager(s).
- >> Si la liste est vide, les mails de contact seront envoyés au(x) manager(s).
- >> Valeurs possibles :
- >> `info`, `contribute`, `request_password`,
- >> `inapropriate_content`, `bug`, `other
- - - `USE_ESTABLISHMENT_FIELD` - - > valeur par défaut : `False` - - >> Si valeur vaut 'True', rajoute un attribut 'establishment'
- >> à l’utilisateur Pod, ce qui permet de gérer plus d’un établissement.
- >> Dans ce cas, les emails de contact par exemple seront envoyés
- >> soit à l’utilisateur soit au(x) manager(s)
- >> de l’établissement de l’utilisateur.
- >> (voir `USER_CONTACT_EMAIL_CASE`)
- >> Également, les emails de fin d’encodage seront envoyés au(x) manager(s)
- >> de l’établissement du propriétaire de la vidéo encodée,
- >> en plus d’un email au propriétaire confirmant la fin d’encodage d’une vidéo.
- -### Encodage - - - `FFMPEG_AUDIO_BITRATE` - - > valeur par défaut : `192k` - - - - - `FFMPEG_CMD` - - > valeur par défaut : `ffmpeg` - - - - - `FFMPEG_CREATE_THUMBNAIL` - - > valeur par défaut : `-vf "fps=1/(%(duration)s/%(nb_thumbnail)s)" -vsync vfr "%(output)s_%%04d.png"` - - - - - `FFMPEG_CRF` - - > valeur par défaut : `20` - - - - - `FFMPEG_EXTRACT_SUBTITLE` - - > valeur par défaut : `-map 0:%(index)s -f webvtt -y "%(output)s" ` - - - - - `FFMPEG_EXTRACT_THUMBNAIL` - - > valeur par défaut : `-map 0:%(index)s -an -c:v copy -y "%(output)s" ` - - - - - `FFMPEG_HLS_COMMON_PARAMS` - - > valeur par défaut : `-c:v %(libx)s -preset %(preset)s -profile:v %(profile)s -pix_fmt yuv420p -level %(level)s -crf %(crf)s -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)" -c:a aac -ar 48000 -max_muxing_queue_size 4000 ` - - - - - `FFMPEG_HLS_ENCODE_PARAMS` - - > valeur par défaut : `-vf "scale=-2:%(height)s" -maxrate %(maxrate)s -bufsize %(bufsize)s -b:a:0 %(ba)s -hls_playlist_type vod -hls_time %(hls_time)s -hls_flags single_file -master_pl_name "livestream%(height)s.m3u8" -y "%(output)s" ` - - - - - `FFMPEG_HLS_TIME` - - > valeur par défaut : `2` - - - - - `FFMPEG_INPUT` - - > valeur par défaut : `-hide_banner -threads %(nb_threads)s -i "%(input)s" ` - - - - - `FFMPEG_LEVEL` - - > valeur par défaut : `3` - - - - - `FFMPEG_LIBX` - - > valeur par défaut : `libx264` - - - - - `FFMPEG_M4A_ENCODE` - - > valeur par défaut : `-vn -c:a aac -b:a %(audio_bitrate)s "%(output)s" ` - - - - - `FFMPEG_MP3_ENCODE` - - > valeur par défaut : `-vn -codec:a libmp3lame -qscale:a 2 -y "%(output)s" ` - - - - - `FFMPEG_MP4_ENCODE` - - > valeur par défaut : `-map 0:v:0 %(map_audio)s -c:v %(libx)s -vf "scale=-2:%(height)s" -preset %(preset)s -profile:v %(profile)s -pix_fmt yuv420p -level %(level)s -crf %(crf)s -maxrate %(maxrate)s -bufsize %(bufsize)s -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)" -max_muxing_queue_size 4000 -c:a aac -ar 48000 -b:a %(ba)s -movflags faststart -y -vsync 0 "%(output)s" ` - - - - - `FFMPEG_NB_THREADS` - - > valeur par défaut : `0` - - - - - `FFMPEG_NB_THUMBNAIL` - - > valeur par défaut : `3` - - - - - `FFMPEG_PRESET` - - > valeur par défaut : `slow` - - - - - `FFMPEG_PROFILE` - - > valeur par défaut : `high` - - - - - `FFMPEG_STUDIO_COMMAND` - - > valeur par défaut : ` -hide_banner -threads %(nb_threads)s %(input)s %(subtime)s -c:a aac -ar 48000 -c:v h264 -profile:v high -pix_fmt yuv420p -crf %(crf)s -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)" -max_muxing_queue_size 4000 -deinterlace ` - - - - - `FFPROBE_CMD` - - > valeur par défaut : `ffprobe` - - - - - `FFPROBE_GET_INFO` - - > valeur par défaut : `%(ffprobe)s -v quiet -show_format -show_streams %(select_streams)s -print_format json -i %(source)s` - - - -### Gestion des fichiers - - - - `FILES_DIR` - - > valeur par défaut : `files` - - >> Nom du répertoire racine où les fichiers "complémentaires"
- >> (hors vidéos etc.) sont téléversés. Notament utilisé par PODFILE
- >> À modifier principalement pour indiquer dans LOCATION votre serveur de cache si elle n’est pas sur la même machine que votre POD.
- - - `FILE_UPLOAD_TEMP_DIR` - - > valeur par défaut : `/var/tmp` - - >> Le répertoire dans lequel stocker temporairement les données (typiquement pour les fichiers plus grands que `FILE_UPLOAD_MAX_MEMORY_SIZE`) lors des téléversements de fichiers.

- >> _ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#file-upload-temp-dir]()_
- - - `MEDIA_ROOT` - - > valeur par défaut : `/pod/media` - - >> Chemin absolu du système de fichiers pointant vers le répertoire qui contiendra les fichiers téléversés par les utilisateurs.

- >> Attention, ce répertoire doit exister

- >> _ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#std:setting-MEDIA_ROOT]()_
- - - `MEDIA_URL` - - > valeur par défaut : `/media/` - - >> prefix url utilisé pour accéder aux fichiers du répertoire media
- - - `STATICFILES_STORAGE` - - > valeur par défaut : `` - - >> Indique à django de compresser automatiquement les fichiers css/js les plus gros lors du collectstatic pour optimiser les tailles de requetes.

- >> À combiner avec un réglage webserver (`gzip_static on;` sur nginx)

- >> _ref: [https://github.com/whs/django-static-compress]()
- - - `STATIC_ROOT` - - > valeur par défaut : `/pod/static` - - >> Le chemin absolu vers le répertoire dans lequel collectstatic rassemble les fichiers statiques en vue du déploiement. Ce chemin sera précisé dans le fichier de configurtation du vhost nginx.

- >> _ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#std:setting-STATIC_ROOT]()_
- - - `STATIC_URL` - - > valeur par défaut : `/static/` - - >> prefix url utilisé pour accèder aux fichiers static
- - - `USE_PODFILE` - - > valeur par défaut : `False` - - >> Utiliser l’application de gestion de fichier fourni avec le projet.
- >> Si False, chaque fichier envoyé ne pourra être utilisé qu’une seule fois.
- - - `VIDEOS_DIR` - - > valeur par défaut : `videos` - - >> Répertoire par défaut pour le téléversement des vidéos.
- -### Langue -Par défaut, Esup-Pod est fournie en Francais et en anglais.
-Vous pouvez tout à fait rajouter des langues comme vous le souhaitez. Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
- - - `LANGUAGES` - - > valeur par défaut : `(('fr', 'Français'), ('en', 'English')))` - - >> Langue disponible et traduite
- - - `LANGUAGE_CODE` - - > valeur par défaut : `fr` - - >> Langue par défaut si non détectée
- -### Divers - - - `ADMINS` - - > valeur par défaut : `[("Name", "adminmail@univ.fr"),]` - - >> Une liste de toutes les personnes qui reçoivent les notifications d’erreurs dans le code.

- >> Lorsque DEBUG=False et qu’une vue lève une exception, Django envoie un courriel à ces personnes contenant les informations complètes de l’exception.

- >> Chaque élément de la liste doit être un tuple au format « (nom complet, adresse électronique) ».

- >> Exemple : `[('John', 'john@example.com'), ('Mary', 'mary@example.com')]`

- >> Dans Pod, les "admins" sont également destinataires des courriels de contact, d’encodage ou de flux RSS si la variable `CONTACT_US_EMAIL` n’est pas renseignée.

- >> __ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#admins]()__
- - - `ALLOWED_HOSTS` - - > valeur par défaut : `['localhost']` - - >> Une liste de chaînes représentant des noms de domaine/d’hôte que ce site Django peut servir.

- >> C’est une mesure de sécurité pour empêcher les attaques d’en-tête Host HTTP, qui sont possibles même avec bien des configurations de serveur Web apparemment sécurisées.

- >> __ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#allowed-hosts]()__
- - - `BASE_DIR` - - > valeur par défaut : `os.path.dirname(os.path.dirname(os.path.abspath(__file__)))` - - >> répertoire de base
- - - `CACHES` - - > valeur par défaut : `{}` - - >> - >> ```python - >> CACHES = { - >> # … default cache config and others - >> # "default": { - >> # "BACKEND": "django.core.cache.backends.locmem.LocMemCache", - >> # }, - >> "default": { - >> "BACKEND": "django_redis.cache.RedisCache", - >> "LOCATION": "redis://127.0.0.1:6379/1", - >> "OPTIONS": { - >> "CLIENT_CLASS": "django_redis.client.DefaultClient", - >> }, - >> }, - >> # Persistent cache setup for select2 (NOT DummyCache or LocMemCache). - >> "select2": { - >> "BACKEND": "django_redis.cache.RedisCache", - >> "LOCATION": "redis://127.0.0.1:6379/2", - >> "OPTIONS": { - >> "CLIENT_CLASS": "django_redis.client.DefaultClient", - >> }, - >> }, - >> } - >> - >> ``` - - - `CSRF_COOKIE_SECURE` - - > valeur par défaut : ` not DEBUG` - - >> Ces 3 variables servent à sécuriser la plateforme en passant l’ensemble des requetes en https. Idem pour les cookies de session et de cross-sites qui seront également sécurisés

- >> Il faut les passer à False en cas d’usage du runserver (phase de développement / debugage)

- >> __ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#secure-ssl-redirect]()__
- - - `DEBUG` - - > valeur par défaut : `True` - - >> Une valeur booléenne qui active ou désactive le mode de débogage.

- >> Ne déployez jamais de site en production avec le réglage DEBUG activé.

- >> __ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#debug]()__
- - - `LOGIN_URL` - - > valeur par défaut : `/authentication_login/` - - >> url de redirection pour l’authentification de l’utilisateur
- >> voir : [https://docs.djangoproject.com/fr/3.2/ref/settings/#login-url]()
- - - `MANAGERS` - - > valeur par défaut : `[]` - - >> Dans Pod, les "managers" sont destinataires des courriels de fin d’encodage (et ainsi des vidéos déposées sur la plateforme).

- >> Le premier manager renseigné est également contact des flus RSS.

- >> Ils sont aussi destinataires des courriels de contact si la variable `CONTACT_US_EMAIL` n’est pas renseignée.

- >> __ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#managers]()__
- - - `PROXY_HOST` - - > valeur par défaut : `` - - >> Utilisation du proxy - host
- - - `PROXY_PORT` - - > valeur par défaut : `` - - >> Utilisation du proxy - port
- - - `SECRET_KEY` - - > valeur par défaut : `A_CHANGER` - - >> La clé secrète d’une installation Django.

- >> Elle est utilisée dans le contexte de la signature cryptographique, et doit être définie à une valeur unique et non prédictible.

- >> Vous pouvez utiliser ce site pour en générer une : [https://djecrety.ir/]()

- >> __ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#secret-key]()__
- - - `SECURE_SSL_REDIRECT` - - > valeur par défaut : `not DEBUG` - - - - - `SESSION_COOKIE_AGE` - - > valeur par défaut : `14400` - - >> _Valeur par défaut : 14400 (secondes, soit 4 heures)_

- >> L’âge des cookies de sessions, en secondes.
- - - `SESSION_COOKIE_SAMESITE` - - > valeur par défaut : `Lax` - - >> Cette option empêche le cookie d’être envoyé dans les requêtes inter-sites, ce qui prévient les attaques CSRF et rend impossible certaines méthodes de vol du cookie de session.
- >> Voir [https://docs.djangoproject.com/en/3.2/ref/settings/#std-setting-SESSION_COOKIE_SAMESITE]()
- - - `SESSION_COOKIE_SECURE` - - > valeur par défaut : ` not DEBUG` - - - - - `SESSION_EXPIRE_AT_BROWSER_CLOSE` - - > valeur par défaut : `True` - - >> Indique s’il faut que la session expire lorsque l’utilisateur ferme son navigateur.
- - - `SITE_ID` - - > valeur par défaut : `1` - - >> L’identifiant (nombre entier) du site actuel. Peut être utilisé pour mettre en place une instance multi-tenant et ainsi gérer dans une même base de données du contenu pour plusieurs sites.

- >> __ref : [https://docs.djangoproject.com/fr/3.2/ref/settings/#site-id]()__
- - - `TEST_SETTINGS` - - > valeur par défaut : `False` - - >> Permet de vérifier si la configuration de la plateforme est en mode test.
- - - `THIRD_PARTY_APPS` - - > valeur par défaut : `[]` - - >> Liste des applications tierces accessibles.
- >> - >> ``` - >> THIRD_PARTY_APPS = ["enrichment", "live"] - >> - >> ``` - - - `TIME_ZONE` - - > valeur par défaut : `UTC` - - >> Une chaîne représentant le fuseau horaire pour cette installation.

- >> __ref: [https://docs.djangoproject.com/fr/3.2/ref/settings/#std:setting-TIME_ZONE]()__
- >> Liste des adresses destinataires des courriels de contact
- -### Obsolescence - - - `ACCOMMODATION_YEARS` - - > valeur par défaut : `{}` - - >> Durée d’obsolescence personnalisée par Affiliation
- >> - >> ``` - >> ACCOMMODATION_YEARS = { - >> 'affiliate': 1 - >> } - >> - >> ``` - - - `ARCHIVE_OWNER_USERNAME` - - > valeur par défaut : `"archive"` - - >> Nom de l’utilisateur pour l’archivage des vidéos.
- - - `POD_ARCHIVE_AFFILIATION` - - > valeur par défaut : `[]` - - >> Affiliations pour lesquelles on souhaite archiver la vidéo plutôt que de la supprimer.
- >> Si l’affiliation du propriétaire est dans cette variable, alors les vidéos sont affectées à un utilisateur précis
- >> que l’on peut spécifier via le paramètre `ARCHIVE_OWNER_USERNAME`.
- >> Elles sont mises en mode brouillon et le mot "archived" est ajouté à leur titre.
- >> Enfin, elles sont également ajoutées à l’ensemble `Vidéo à Supprimer` (accessible via l’interface d’admin).
- >> - >> ```python - >> POD_ARCHIVE_AFFILIATION = ['faculty', - >> 'staff', - >> 'employee', - >> 'affiliate', - >> 'alum', - >> 'library-walk-in', - >> 'researcher', - >> 'retired', - >> 'emeritus', - >> 'teacher', - >> 'registered-reader', - >> 'member'] - >> - >> ``` - - - `WARN_DEADLINES` - - > valeur par défaut : `[60, 30, 7]` - - >> Liste de jours de délais avant l’obsolescence de la vidéo.
- >> À chaque délai, le propriétaire reçoit un mail d’avertissement pour éventuellement changer la date d’obsolescence de sa vidéo.
- -### Modèle - - - `COOKIE_LEARN_MORE` - - > valeur par défaut : `` - - >> Ce paramètre permet d’afficher un lien "En savoir plus"
- >> sur la boite de dialogue d’information sur l’usage des cookies dans Pod.
- >> On peut préciser un lien vers les mentions légales ou page DPO.
- - - `DARKMODE_ENABLED` - - > valeur par défaut : `False` - - >> Activation du mode sombre
- - - `DYSLEXIAMODE_ENABLED` - - > valeur par défaut : `False` - - >> Activation du mode dyslexie
- - - `HIDE_CHANNEL_TAB` - - > valeur par défaut : `False` - - >> Si True, permet de cacher l’onglet chaine dans la barre de menu du haut.
- - - `HIDE_DISCIPLINES` - - > valeur par défaut : `False` - - >> Si True, permet de ne pas afficher les disciplines dans la colonne de droite
- - - `HIDE_LANGUAGE_SELECTOR` - - > valeur par défaut : `False` - - >> Si True, permet de cacher le sélecteur de langue dans le menu du haut.
- - - `HIDE_SHARE` - - > valeur par défaut : `False` - - >> Si True, permet de ne pas afficher les liens de partage sur les réseaux sociaux dans la colonne de droite.
- - - `HIDE_TAGS` - - > valeur par défaut : `False` - - >> Si True, permet de ne pas afficher le nuage de mots clés dans la colonne de droite.
- - - `HIDE_TYPES` - - > valeur par défaut : `False` - - >> si True, permet de ne pas afficher la liste des types dans la colonne de droite
- - - `HIDE_TYPES_TAB` - - > valeur par défaut : `False` - - >> Si True, permet de cacher l’entrée 'type' dans le menu de navigation.
- - - `HIDE_USERNAME` - - > valeur par défaut : `False` - - >> Voir description dans authentification
- >> Si valeur vaut 'True', le username de l’utilisateur ne sera pas visible sur la plate-forme Pod et
- >> si la valeur vaut 'False' le username sera affiché aux utilisateurs authentifiés. (pour respecter le RGPD)
- - - `HIDE_USER_FILTER` - - > valeur par défaut : `False` - - >> Si 'True', le filtre des vidéos par utilisateur ne sera plus visible
- >> si 'False' le filtre sera visible qu’aux personnes authentifiées.
- >> (pour respecter le RGPD)
- - - `HIDE_USER_TAB` - - > valeur par défaut : `False` - - >> Si valeur vaut 'True', l’onglet Utilisateur ne sera pas visible
- >> et si la valeur vaut 'False' l’onglet Utilisateur ne sera visible
- >> qu’aux personnes authentifiées.
- >> (pour respecter le RGPD)
- - - `HOMEPAGE_NB_VIDEOS` - - > valeur par défaut : `12` - - >> Nombre de vidéos à afficher sur la page d’accueil.
- - - `HOMEPAGE_SHOWS_PASSWORDED` - - > valeur par défaut : `False` - - >> Afficher les vidéos dont l’accès est protégé par mot de passe sur la page d’accueil.
- - - `HOMEPAGE_SHOWS_RESTRICTED` - - > valeur par défaut : `False` - - >> Afficher les vidéos dont l’accès est protégé par authentification sur la page d’accueil.
- - - `MENUBAR_HIDE_INACTIVE_OWNERS` - - > valeur par défaut : `True` - - >> Les utilisateurs inactifs ne sont plus affichés dans la barre de menu utilisateur.
- - - `MENUBAR_SHOW_STAFF_OWNERS_ONLY` - - > valeur par défaut : `False` - - >> Les utilisateurs non staff ne sont plus affichés dans la barre de menu utilisateur.
- - - `SHIB_NAME` - - > valeur par défaut : `Identify Federation` - - >> Nom de la fédération d’identité utilisée
- >> Affiché sur le bouton de connexion si l’authentification Shibboleth est utilisée.
- - - `SHOW_EVENTS_ON_HOMEPAGE` - - > valeur par défaut : `False` - - >> Si True, affiche les prochains évènements sur la page d’accueil.
- - - `SHOW_ONLY_PARENT_THEMES` - - > valeur par défaut : `False` - - >> Si True, affiche uniquement les thèmes de premier niveau dans l’onglet 'Chaîne'.
- - - `TEMPLATE_VISIBLE_SETTINGS` - - > valeur par défaut : `{}` - - >> - >> ``` - >> TEMPLATE_VISIBLE_SETTINGS = { - >> # Titre du site. - >> 'TITLE_SITE': 'Pod', - >> - >> # Description du site. - >> 'DESC_SITE': 'L’objectif d’Esup-Pod est de faciliter la mise à disposition de vidéos et ainsi d’encourager son utilisation dans l’enseignement et la recherche.', - >> - >> # Titre de l’établissement. - >> 'TITLE_ETB': 'University name', - >> - >> # Logo affiché en haut à gauche sur toutes les pages. - >> # Doit se situer dans le répertoire static - >> 'LOGO_SITE': 'img/logoPod.svg', - >> - >> # Logo affiché dans le footer sur toutes les pages. - >> # Doit se situer dans le répertoire static - >> 'LOGO_ETB': 'img/esup-pod.svg', - >> - >> # Logo affiché sur le player video. - >> # Doit se situer dans le répertoire static - >> 'LOGO_PLAYER': 'img/pod_favicon.svg', - >> - >> # Lien de destination du logo affiché sur le player - >> 'LINK_PLAYER': '', - >> - >> # Texte affiché dans le footer. Une ligne par entrée, accepte du code html. - >> # Par exemple : - >> # ( '42, rue Paul Duez', - >> # '59000 Lille - France', - >> # ('> # ' target="_blank">Google maps') ) - >> 'FOOTER_TEXT': ('',), - >> - >> # Icone affichée dans la barre d'adresse du navigateur - >> 'FAVICON': 'img/pod_favicon.svg', - >> - >> # Si souhaitée, à créer et sauvegarder - >> # dans le répertoire static de l'application custom et - >> # préciser le chemin d'accès. Par exemple : "custom/etab.css" - >> 'CSS_OVERRIDE': '', - >> - >> # Vous pouvez créer un template dans votre application custom et - >> # indiquer son chemin dans cette variable pour que ce code html, - >> # ce template soit affiché en haut de votre page, le code est ajouté - >> # juste après la balise body.(Hors iframe) - >> # Si le fichier créé est - >> # '/opt/django_projects/podv3/pod/custom/templates/custom/preheader.html' - >> # alors la variable doit prendre la valeur 'custom/preheader.html' - >> 'PRE_HEADER_TEMPLATE': '', - >> - >> # Idem que pre-header, le code contenu dans le template - >> # sera affiché juste avant la fermeture du body. (Or iframe) - >> 'POST_FOOTER_TEMPLATE': '', - >> - >> # vous pouvez créer un template dans votre application custom - >> # pour y intégrer votre code Piwik ou Google analytics. - >> # Ce template est inséré dans toutes les pages de la plateforme, - >> # y compris en mode iframe - >> 'TRACKING_TEMPLATE': '', - >> } - >> - >> ``` - -### Transcodage - - - `TRANSCRIPTION_AUDIO_SPLIT_TIME` - - > valeur par défaut : `600` - - >> Découpage de l’audio pour la transcription.
- - - `TRANSCRIPTION_MODEL_PARAM` - - > valeur par défaut : `{}` - - >> Paramétrage des modèles pour la transcription
- >> Voir la documentation à cette adresse : [https://www.esup-portail.org/wiki/display/ES/Installation+de+l%27autotranscription+en+Pod+V3]()
- >> Pour télécharger les Modeles Vosk : [https://alphacephei.com/vosk/models]()
- >> - >> ``` - >> TRANSCRIPTION_MODEL_PARAM = { - >> # le modèle stt - >> 'STT': { - >> 'fr': { - >> 'model': "/path/to/project/Esup-Pod/transcription/model_fr/stt/output_graph.tflite", - >> 'scorer': "/path/to/project/Esup-Pod/transcription/model_fr/stt/kenlm.scorer", - >> } - >> }, - >> # le modèle vosk - >> 'VOSK': { - >> 'fr': { - >> 'model': "/path/of/project/Esup-Pod/transcription/model_fr/vosk/vosk-model-fr-0.6-linto-2.2.0", - >> } - >> } - >> } - >> - >> ``` - - - `TRANSCRIPTION_NORMALIZE` - - > valeur par défaut : `False` - - >> Activation de la normalisation de l’audio avant sa transcription.
- - - `TRANSCRIPTION_NORMALIZE_TARGET_LEVEL` - - > valeur par défaut : `-16.0` - - >> Niveau de normalisation de l’audio avant sa transcription.
- - - `TRANSCRIPTION_STT_SENTENCE_BLANK_SPLIT_TIME` - - > valeur par défaut : `0.5` - - >> Temps maximum en secondes des blancs entre chaque mot pour le decoupage des sous-titres avec l’outil STT.
- - - `TRANSCRIPTION_STT_SENTENCE_MAX_LENGTH` - - > valeur par défaut : `3` - - >> Temps en secondes maximum pour une phrase lors de la transcription avec l’outil STT.
- - - `TRANSCRIPTION_TYPE` - - > valeur par défaut : `STT` - - >> Choix de l’outil pour la transcription : STT ou VOSK.
- - - `TRANSCRIPT_VIDEO` - - > valeur par défaut : `start_transcript` - - >> Fonction appelée pour lancer la transcription des vidéos.
- - - `USE_TRANSCRIPTION` - - > valeur par défaut : `False` - - >> Activation de la transcription.
- -## Configuration Générale de la plateforme Esup_Pod - - -### Configuration application authentification. - - - `AFFILIATION` - - > valeur par défaut : `` - - >> Valeurs possibles pour l’affiliation du compte.
- - - `AFFILIATION_EVENT` - - > valeur par défaut : `` - - >> Groupes ou affiliations des personnes autorisées à créer un évènement.
- - - `AFFILIATION_STAFF` - - > valeur par défaut : `` - - >> Les personnes ayant pour affiliation les valeurs
- >> renseignées dans cette variable ont automatiquement
- >> la valeur staff de leur compte à True.
- - - `AUTH_CAS_USER_SEARCH` - - > valeur par défaut : `user` - - >> Variable utilisée pour trouver les informations de l’individu
- >> connecté dans le fichier renvoyé par le CAS lors de l’authentification.
- - - `AUTH_LDAP_BIND_DN` - - > valeur par défaut : `` - - >> Identifiant (DN) du compte pour se connecter au serveur LDAP.
- - - `AUTH_LDAP_BIND_PASSWORD` - - > valeur par défaut : `` - - >> Mot de passe du compte pour se connecter au serveur LDAP.
- - - `AUTH_LDAP_USER_SEARCH` - - > valeur par défaut : `` - - >> Filtre LDAP permettant la recherche de l’individu dans le serveur LDAP.
- - - `AUTH_TYPE` - - > valeur par défaut : `` - - >> Type d’authentification possible sur votre instance.
- >> Choix : local, CAS, OIDC, Shibboleth
- - - `CAS_ADMIN_AUTH` - - > valeur par défaut : `False` - - >> Permet d’activer l’authentification CAS pour la partie admin
- >> Voir : [https://pypi.org/project/django-cas-sso/]()
- - - `CAS_FORCE_LOWERCASE_USERNAME` - - > valeur par défaut : `False` - - >> Forcer le passage en minuscule du nom d’utilisateur CAS
- >> (permet de prévenir des doubles créations de comptes dans certain cas).
- - - `CAS_GATEWAY` - - > valeur par défaut : `False` - - >> Si True, authentifie automatiquement l’individu
- >> si déjà authentifié sur le serveur CAS
- - - `CAS_LOGOUT_COMPLETELY` - - > valeur par défaut : `True` - - >> Voir [https://github.com/kstateome/django-cas]()
- - - `CAS_SERVER_URL` - - > valeur par défaut : `sso_cas` - - >> Url du serveur CAS de l’établissement. Format http://url_cas
- - - `CREATE_GROUP_FROM_AFFILIATION` - - > valeur par défaut : `False` - - >> Si True, des groupes sont créés automatiquement
- >> à partir des affiliations des individus qui se connectent sur la plateforme
- >> et l’individu qui se connecte est ajouté automatiquement à ces groupes.
- - - `CREATE_GROUP_FROM_GROUPS` - - > valeur par défaut : `False` - - >> Si True, des groupes sont créés automatiquement
- >> à partir des groupes (attribut groups à memberOf)
- >> des individus qui se connectent sur la plateforme
- >> et l’individu qui se connecte est ajouté automatiquement à ces groupes
- - - `DEFAULT_AFFILIATION` - - > valeur par défaut : `` - - >> Affiliation par défaut d’un utilisateur authentifié par OIDC.
- >> Ce contenu sera comparé à la liste AFFILIATION_STAFF pour déterminer si l’utilisateur doit être admin Django
- - - `ESTABLISHMENTS` - - > valeur par défaut : `` - - >> [TODO] À compléter
- - - `GROUP_STAFF` - - > valeur par défaut : `AFFILIATION_STAFF` - - >> utilisé dans populatedCasbackend
- - - `HIDE_LOCAL_LOGIN` - - > valeur par défaut : `False` - - >> Si True, masque l’authentification locale
- - - `HIDE_USERNAME` - - > valeur par défaut : `False` - - >> Si valeur vaut `True`, le username de l’utilisateur ne sera pas visible sur la plate-forme Pod et si la valeur vaut `False` le username sera affiché aux utilisateurs authentifiés. (pour respecter le RGPD)
- - - `LDAP` - - > valeur par défaut : `` - - >> - LDAP (Interroge le serveur LDAP pour renseigner les champs)
- - - `LDAP_SERVER` - - > valeur par défaut : `` - - >> Information de connection au serveur LDAP.
- >> Le champ url peut contenir une ou plusieurs url
- >> pour ajouter des hôtes de référence, exemple :
- >> Si un seul host :
- >> - >> `{'url': "ldap.univ.fr'', 'port': 389, 'use_ssl': False}` - >> Si plusieurs : - >> - >> `{'url': ("ldap.univ.fr'',"ldap2.univ.fr"), 'port': 389, 'use_ssl': False}` - - - `OIDC_CLAIM_FAMILY_NAME` - - > valeur par défaut : `family_name` - - - - - `OIDC_CLAIM_GIVEN_NAME` - - > valeur par défaut : `given_name` - - >> Noms des Claim permettant de récupérer les attributs nom, prénom, email
- - - `OIDC_DEFAULT_ACCESS_GROUP_CODE_NAMES` - - > valeur par défaut : `[]` - - >> Groupes d’accès attribués par défaut à un nouvel utilisateur authentifié par OIDC
- - - `OIDC_DEFAULT_AFFILIATION` - - > valeur par défaut : `` - - >> Affiliation par défaut d’un utilisateur authentifié par OIDC.
- >> Ce contenu sera comparé à la liste AFFILIATION_STAFF pour déterminer si l’utilisateur doit être admin Django
- - - `OIDC_NAME` - - > valeur par défaut : `` - - >> Nom du Service Provider OIDC
- - - `OIDC_OP_AUTHORIZATION_ENDPOINT` - - > valeur par défaut : `https` - - - - - `OIDC_OP_JWKS_ENDPOINT` - - > valeur par défaut : `https` - - >> Différents paramètres pour OIDC
- >> tant que `mozilla_django_oidc` n’accepte pas le mécanisme de discovery
- >> ref: [https://github.com/mozilla/mozilla-django-oidc/pull/309]()
- - - `OIDC_OP_TOKEN_ENDPOINT` - - > valeur par défaut : `https` - - - - - `OIDC_OP_USER_ENDPOINT` - - > valeur par défaut : `https` - - - - - `OIDC_RP_CLIENT_ID` - - > valeur par défaut : `os.environ` - - - - - `OIDC_RP_CLIENT_SECRET` - - > valeur par défaut : `os.environ` - - >> - >> `CLIENT_ID` et `CLIENT_SECRET` de OIDC sont plutôt à positionner - >> à travers des variables d’environnement. - - - `OIDC_RP_SIGN_ALGO` - - > valeur par défaut : `` - - - - - `POPULATE_USER` - - > valeur par défaut : `None` - - >> Si utilisation de la connection CAS, renseigne les champs du compte
- >> de la personne depuis une source externe.
- >> Valeurs possibles :
- >> - None (pas de renseignement),
- >> - CAS (renseigne les champs depuis les informations renvoyées par le CAS),
- - - `REMOTE_USER_HEADER` - - > valeur par défaut : `REMOTE_USER` - - >> Nom de l’attribut dans les headers qui sert à identifier
- >> l’utilisateur connecté avec Shibboleth.
- - - `SHIBBOLETH_ATTRIBUTE_MAP` - - > valeur par défaut : `` - - >> Mapping des attributs entre Shibboleth et la classe utilisateur
- - - `SHIBBOLETH_STAFF_ALLOWED_DOMAINS` - - > valeur par défaut : `` - - >> Permettre à l’utilisateur d’un domaine d’être membre du personnel.
- >> Si vide, tous les domaines seront autorisés.
- - - `SHIB_LOGOUT_URL` - - > valeur par défaut : `` - - >> URL de déconnexion à votre instance Shibboleth
- - - `SHIB_NAME` - - > valeur par défaut : `` - - >> Nom de la fédération d’identité utilisée.
- - - `SHIB_URL` - - > valeur par défaut : `` - - >> URL de connexion à votre instance Shibboleth.
- - - `USER_CAS_MAPPING_ATTRIBUTES` - - > valeur par défaut : `` - - >> Liste de correspondance entre les champs d’un compte de Pod
- >> et les champs renvoyés par le CAS.
- - - `USER_LDAP_MAPPING_ATTRIBUTES` - - > valeur par défaut : `` - - >> Liste de correspondance entre les champs d’un compte de Pod
- >> et les champs renvoyés par le LDAP.
- - - `USE_CAS` - - > valeur par défaut : `False` - - >> Activation de l’authentification CAS en plus de l’authentification locale.
- - - `USE_OIDC` - - > valeur par défaut : `False` - - >> Mettre à True pour utiliser l’authentification OpenID Connect.
- - - `USE_SHIB` - - > valeur par défaut : `False` - - >> Mettre à True pour utiliser l’authentification Shibboleth.
- -### Configuration application chapter. - -### Configuration application completion - - - `ACTIVE_MODEL_ENRICH` - - > valeur par défaut : `False` - - >> Définissez à True pour activer la case à cocher dans l’édition des sous-titres.
- - - `ALL_LANG_CHOICES` - - > valeur par défaut : `` - - >> liste toutes les langues pour l’ajout de fichier de sous-titre
- >> voir le fichier `pod/main/lang_settings.py`.
- - - `DEFAULT_LANG_TRACK` - - > valeur par défaut : `fr` - - >> langue par défaut pour l’ajout de piste à une vidéo.
- - - `KIND_CHOICES` - - > valeur par défaut : `` - - >> Liste de types de piste possibles pour une vidéo (sous-titre, légende etc.)
- - - `LANG_CHOICES` - - > valeur par défaut : `` - - >> Liste des langues proposées lors de l’ajout des vidéos.
- >> Affichés en dessous d’une vidéo, les choix sont aussi utilisés pour affiner la recherche.
- - - `LINK_SUPERPOSITION` - - > valeur par défaut : `False` - - >> Si valeur vaut 'True', les URLs contenues dans le texte de superposition seront transformées, à la lecture de la vidéo, en liens cliquables.
- - - `MODEL_COMPILE_DIR` - - > valeur par défaut : `/path/of/project/Esup-Pod/compile-model` - - >> Paramétrage des chemins du modèle pour la compilation
- >> Pour télécharger les modèles : [https://alphacephei.com/vosk/lm#update-process]()
- >> Ajouter le modèle dans les sous-dossier de la langue correspondante
- >> Exemple pour le français : `/path/of/project/Esup-Pod/compile-model/fr/`
- - - `PREF_LANG_CHOICES` - - > valeur par défaut : `` - - >> liste des langues à afficher en premier dans la liste des toutes les langues
- >> voir le fichier `pod/main/lang_settings.py`
- - - `ROLE_CHOICES` - - > valeur par défaut : `` - - >> Liste de rôles possibles pour un contributeur.
- - - `TRANSCRIPTION_MODEL_PARAM` - - > valeur par défaut : `` - - >> Paramétrage des modèles pour la transcription
- >> Voir la documentation à cette adresse :
- >> [https://www.esup-portail.org/wiki/display/ES/Installation+de+l%27autotranscription+en+Pod+V3]()
- >> Pour télécharger les modèles Vosk : [https://alphacephei.com/vosk/models]()
- >> - >> ```python - >> TRANSCRIPTION_MODEL_PARAM = { - >> # le modèle stt - >> 'STT': { - >> 'fr': { - >> 'model': "/path/to/project/Esup-Pod/transcription/model_fr/stt/output_graph.tflite", - >> 'scorer': "/path/to/project/Esup-Pod/transcription/model_fr/stt/kenlm.scorer", - >> } - >> }, - >> # le modèle vosk - >> 'VOSK': { - >> 'fr': { - >> 'model': "/path/of/project/Esup-Pod/transcription/model_fr/vosk/vosk-model-fr-0.6-linto-2.2.0", - >> } - >> } - >> } - >> - >> ``` - - - `TRANSCRIPTION_TYPE` - - > valeur par défaut : `STT` - - >> STT ou VOSK
- - - `USE_ENRICH_READY` - - > valeur par défaut : `False ` - - >> voir `ACTIVE_MODEL_ENRICH`
- -### Configuration application enrichment - -### Configuration application d'import vidéo -Application Import_video permettant d'importer des vidéos externes dans Pod.
-Mettre `USE_IMPORT_VIDEO` à True pour activer cette application.
- - - `RESTRICT_EDIT_IMPORT_VIDEO_ACCESS_TO_STAFF_ONLY` - - > valeur par défaut : `True` - - >> Seuls les utilisateurs "staff" pourront importer des vidéos
- - - `USE_IMPORT_VIDEO` - - > valeur par défaut : `True` - - >> Activation de l’application d'import des vidéos
- -### Configuration application live - - - `AFFILIATION_EVENT` - - > valeur par défaut : `['faculty', 'employee', 'staff']` - - >> Groupes ou affiliations des personnes autorisées à créer un évènement.
- - - `BROADCASTER_PILOTING_SOFTWARE` - - > valeur par défaut : `[]` - - >> Types de logiciel de serveur de streaming utilisés.
- >> Actuellement disponible Wowza. Il faut préciser cette valeur pour l’activer `['Wowza', ]`
- >> Si vous utilisez une autre logiciel,
- >> il faut développer une interface dans `pod/live/pilotingInterface.py`
- - - `DEFAULT_EVENT_PATH` - - > valeur par défaut : `` - - >> Chemin racine du répertoire où sont déposés temporairement les enregistrements des évènements éffectués depuis POD pour convertion en ressource vidéo (VOD)
- - - `DEFAULT_EVENT_THUMBNAIL` - - > valeur par défaut : `/img/default-event.svg` - - >> Image par défaut affichée comme poster ou vignette, utilisée pour présenter l’évènement.
- >> Cette image doit se situer dans le répertoire `static`.
- - - `DEFAULT_EVENT_TYPE_ID` - - > valeur par défaut : `1` - - >> Type par défaut affecté à un évènement direct (en général, le type ayant pour identifiant '1' est 'Other')
- - - `DEFAULT_THUMBNAIL` - - > valeur par défaut : `img/default.svg` - - >> Image par défaut affichée comme poster ou vignette, utilisée pour présenter la vidéo.
- >> Cette image doit se situer dans le répertoire static.
- - - `EMAIL_ON_EVENT_SCHEDULING` - - > valeur par défaut : `True` - - >> Si True, un courriel est envoyé aux managers et à l’auteur (si DEBUG est à False) à la création/modification d’un event.
- - - `EVENT_ACTIVE_AUTO_START` - - > valeur par défaut : `False` - - >> Permet de lancer automatiquement l’enregistrement sur l’interface utilisée (wowza, ) sur le broadcaster et spécifié par `BROADCASTER_PILOTING_SOFTWARE`
- - - `EVENT_GROUP_ADMIN` - - > valeur par défaut : `event admin` - - >> Permet de préciser le nom du groupe dans lequel les utilisateurs peuvent planifier un évènement sur plusieurs jours.
- - - `HEARTBEAT_DELAY` - - > valeur par défaut : `45` - - >> Temps (en secondes) entre deux envois d’un signal au serveur, pour signaler la présence sur un live.
- >> Peut être augmenté en cas de perte de performance, mais au détriment de la qualité du comptage des valeurs.
- - - `LIVE_CELERY_TRANSCRIPTION ` - - > valeur par défaut : `False` - - - >> Activer la transcription déportée sur une machine distante.
- - - `LIVE_TRANSCRIPTIONS_FOLDER` - - > valeur par défaut : `` - - - >> Dossier contenat les fichiers de sous-titre au format vtt pour les directs
- - - `LIVE_VOSK_MODEL` - - > valeur par défaut : `{}` - - - >> Paramétrage des modèles pour la transcription des directs
- >> La documentation sera présente prochaînement
- >> Pour télécharger les Modèles Vosk : [https://alphacephei.com/vosk/models]()
- >> - >> ``` - >> LIVE_VOSK_MODEL = { - >> 'fr': { - >> 'model': "/path/of/project/django_projects/transcription/live/fr/vosk-model-small-fr-0.22", - >> } - >> } - >> - >> ``` - - - `USE_BBB` - - > valeur par défaut : `True` - - >> Utilisation de BigBlueButton - [TODO] À retirer dans les futures versions de Pod
- - - `USE_BBB_LIVE` - - > valeur par défaut : `False ` - - >> Utilisation du système de diffusion de Webinaires en lien avec BigBlueButton - [TODO] À retirer dans les futures versions de Pod
- - - `USE_LIVE_TRANSCRIPTION` - - > valeur par défaut : `False` - - - >> Activer l'auto-transcription pour les directs
- - - `VIEW_EXPIRATION_DELAY` - - > valeur par défaut : `60` - - >> Délai (en seconde) selon lequel une vue est considérée comme expirée si elle n’a pas renvoyé de signal depuis.
- -### Configuration application LTI - - - `LTI_ENABLED` - - > valeur par défaut : `False` - - >> Configuration / Activation du LTI voir pod/main/settings.py L.224
- - - `PYLTI_CONFIG` - - > valeur par défaut : `{}` - - >> Cette variable permet de configurer l’application cliente et le secret partagé
- >> - >> ``` - >> PYLTI_CONFIG = { - >> 'consumers': { - >> '': { - >> 'secret': '' - >> } - >> } - >> } - >> - >> ``` - -### Configuration application main - - - `USE_BBB` - - > valeur par défaut : `True` - - >> Utilisation de BigBlueButton - [TODO] À retirer dans les futures versions de Pod
- - - `USE_BBB_LIVE` - - > valeur par défaut : `False ` - - >> Utilisation du système de diffusion de Webinaires en lien avec BigBlueButton - [TODO] À retirer dans les futures versions de Pod
- - - `USE_IMPORT_VIDEO` - - > valeur par défaut : `True` - - >> Activation de l’application d'import des vidéos
- - - `USE_MEETING` - - > valeur par défaut : `False` - - >> Activation de l’application meeting
- - - `USE_OPENCAST_STUDIO` - - > valeur par défaut : `False` - - >> Activation du studio [https://opencast.org/](Opencast)
- - - `VERSION` - - > valeur par défaut : `` - - >> Version courante du projet
- - - `HOMEPAGE_VIEW_VIDEOS_FROM_NON_VISIBLE_CHANNELS` - - > valeur par défaut : `False` - - >> Affiche les vidéos de chaines non visibles sur la page d'accueil
- -### Configuration application meeting - -Application Meeting pour la gestion de reunion avec BBB.
-Mettre `USE_MEETING` à True pour activer cette application.
-`BBB_API_URL` et `BBB_SECRET_KEY` sont obligatoires pour faire fonctionner l’application
- - - `BBB_API_URL` - - > valeur par défaut : `` - - >> Indiquer l’URL API de BBB par ex `https://webconf.univ.fr/bigbluebutton/api
- - - `BBB_LOGOUT_URL` - - > valeur par défaut : `` - - >> Indiquer l’URL de retour au moment où vous quittez la réunion BBB. Ce champ est optionnel.
- - - `BBB_MEETING_INFO` - - > valeur par défaut : `{}` - - >> Dictionnaire de clé:valeur permettant d’afficher les informations d’une session de réunion dans BBB
- >> Voici la liste par défaut
- >> - >> ``` - >> BBB_MEETING_INFO: - >> { - >> "meetingName": _("Meeting name"), - >> "hasUserJoined": _("Has user joined?"), - >> "recording": _("Recording"), - >> "participantCount": _("Participant count"), - >> "listenerCount": _("Listener count"), - >> "moderatorCount": _("Moderator count"), - >> "attendees": _("Attendees"), - >> "attendee": _("Attendee"), - >> "fullName": _("Full name"), - >> "role": _("Role"), - >> } - >> - >> ``` - - - `BBB_SECRET_KEY` - - > valeur par défaut : `` - - >> Clé de votre serveur BBB.
- >> Vous pouvez récupérer cette clé à l’aide de la commande `bbb-conf --secret` sur le serveur BBB.
- - - `DEFAULT_MEETING_THUMBNAIL` - - > valeur par défaut : `/img/default-meeting.svg` - - >> Image par défaut affichée comme poster ou vignette, utilisée pour présenter la réunion.
- >> Cette image doit se situer dans le répertoire `static`.
- - - `MEETING_DATE_FIELDS` - - > valeur par défaut : `()` - - >> liste des champs du formulaire de creation d’une reunion
- >> les champs sont regroupés dans un ensemble de champs
- >> - >> ``` - >> MEETING_DATE_FIELDS: - >> ( - >> "start", - >> "start_time", - >> "expected_duration", - >> ) - >> - >> ``` - - - `MEETING_DISABLE_RECORD` - - > valeur par défaut : `True` - - >> Mettre à True pour désactiver les enregistrements de réunion
- >> Configuration de l’enregistrement des réunions.
- >> Ce champ n’est pas pris en compte si `MEETING_DISABLE_RECORD = True`.
- - - `MEETING_MAIN_FIELDS` - - > valeur par défaut : `()` - - >> Permet de définir les champs principaux du formulaire de création d’une réunion
- >> les champs principaux sont affichés directement dans la page de formulaire d’une réunion
- >> - >> ``` - >> MEETING_MAIN_FIELDS: - >> ( - >> "name", - >> "owner", - >> "additional_owners", - >> "attendee_password", - >> "is_restricted", - >> "restrict_access_to_groups", - >> ) - >> - >> ``` - - - `MEETING_MAX_DURATION` - - > valeur par défaut : `5` - - >> permet de définir la durée maximum pour une reunion
- >> (en heure)
- - - `MEETING_PRE_UPLOAD_SLIDES` - - > valeur par défaut : `` - - - >> Diaporama préchargé pour les réunions virtuelles.
- >> Un utilisateur peut remplacer cette valeur en choisissant un diaporama lors de la création d'une réunion virtuelle.
- >> Doit se trouver dans le répertoire statique.
- - - `MEETING_RECORD_FIELDS` - - > valeur par défaut : `()` - - >> ensemble des champs qui seront cachés si `MEETING_DISABLE_RECORD` est défini à true.
- >> - >> ``` - >> MEETING_RECORD_FIELDS: ("record", "auto_start_recording", "allow_start_stop_recording") - >> - >> ``` - - - `MEETING_RECURRING_FIELDS` - - > valeur par défaut : `()` - - >> Liste de tous les champs permettant de définir la récurrence d’une reunion
- >> tous ces champs sont regroupés dans un ensemble de champs affichés dans une modale
- >> - >> ``` - >> MEETING_RECURRING_FIELDS: - >> ( - >> "recurrence", - >> "frequency", - >> "recurring_until", - >> "nb_occurrences", - >> "weekdays", - >> "monthly_type", - >> ) - >> - >> ``` - - - `RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY` - - > valeur par défaut : `False` - - >> Seuls les utilisateurs "staff" pourront éditer les réunions
- - - `USE_MEETING` - - > valeur par défaut : `False` - - >> Activer l’application meeting
- -### Configuration application playlist - -### Configuration application podfile - - - `FILES_DIR` - - > valeur par défaut : `files` - - >> Nom du répertoire racine où les fichiers "complémentaires"
- >> (hors vidéos etc.) sont téléversés. Notament utilisé par PODFILE
- >> À modifier principalement pour indiquer dans LOCATION votre serveur de cache si elle n’est pas sur la même machine que votre POD.
- - - `FILE_ALLOWED_EXTENSIONS` - - > valeur par défaut : `('doc', 'docx', 'odt', 'pdf', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'txt', 'html', 'htm', 'vtt', 'srt')` - - >> Extensions autorisées pour les documents téléversés dans le gestionnaire de fichier (en minuscules).
- - - `FILE_MAX_UPLOAD_SIZE` - - > valeur par défaut : `10` - - >> Poids maximum en Mo par fichier téléversé dans le gestionnaire de fichier
- - - `IMAGE_ALLOWED_EXTENSIONS` - - > valeur par défaut : `('jpg', 'jpeg', 'bmp', 'png', 'gif', 'tiff', 'webp')` - - >> Extensions autorisées pour les images téléversées dans le gestionnaire de fichier. (en minuscules)
- -### Configuration application recorder - - - `ALLOW_MANUAL_RECORDING_CLAIMING` - - > valeur par défaut : `False` - - >> si True, active un lien dans le menu de l’utilisateur permettant de réclamer un enregistrement
- - - `ALLOW_RECORDER_MANAGER_CHOICE_VID_OWNER` - - > valeur par défaut : `True` - - >> Si True, le manager de l’enregistreur pourra choisir un propriétaire de l’enregistrement.
- - - `DEFAULT_RECORDER_ID` - - > valeur par défaut : `1` - - >> Ajoute un enregistreur par défaut à un enregistrement non identifiable (mauvais chemin dans le dépôt FTP)
- - - `DEFAULT_RECORDER_PATH` - - > valeur par défaut : `/data/ftp-pod/ftp/` - - >> Chemin racine du répertoire où sont déposés les enregistrements
- >> (chemin du serveur FTP).
- - - `DEFAULT_RECORDER_TYPE_ID` - - > valeur par défaut : `1` - - >> Identifiant du type de vidéo par défaut (si non spécifié).
- >> (Exemple : 3 pour Colloque/conférence, 4 pour Cours...)
- - - `DEFAULT_RECORDER_USER_ID` - - > valeur par défaut : `1` - - >> Identifiant du propriétaire par défaut (si non spécifié) des enregistrements déposés.
- - - `OPENCAST_DEFAULT_PRESENTER` - - > valeur par défaut : `mid` - - >> Permet de spécifier la valeur par défaut du placement de la vidéo du
- >> presenteur par rapport à la vidéo de présentation (écran)
- >> les valeurs possibles sont :
- >> * "mid" (écran et caméra ont la même taille)
- >> * "piph" (le presenteur, caméra, est incrusté dans la vidéo de présentation, en haut à droite)
- >> * "pipb" (le presenteur, caméra, est incrusté dans la vidéo de présentation, en bas à droite)
- >> Contenu par défaut du fichier xml pour créer le mediapackage pour le studio.
- >> Ce fichier va contenir toutes les spécificités de l’enregistrement
- >> (source, cutting, title, presenter etc.)
- - - `OPENCAST_FILES_DIR` - - > valeur par défaut : `opencast-files` - - >> Permet de spécifier le dossier de stockage des enregistrements du studio avant traitement.
- - - `OPENCAST_MEDIAPACKAGE` - - > valeur par défaut : `-> see xml content` - - >> Contenu par défaut du fichier xml pour créer le mediapackage pour le studio. Ce fichier va contenir toutes les spécificités de l’enregistrement (source, cutting, title, presenter etc.)
- >> - >> ``` - >> OPENCAST_MEDIAPACKAGE = """ - >> - >> - >> - >> - >> - >> """ - >> - >> ``` - - - `PUBLIC_RECORD_DIR` - - > valeur par défaut : `records` - - >> Chemin d’accès web (public) au répertoire de dépot des enregistrements (`DEFAULT_RECORDER_PATH`).
- >> Attention : penser à modifier la conf de NGINX.
- - - `RECORDER_ADDITIONAL_FIELDS` - - > valeur par défaut : `()` - - >> Liste des champs supplémentaires pour le formulaire des enregistreurs. Cette liste reprend le nom des champs correspondants aux paramètres d’édition d’une vidéo (Discipline, Chaine, Theme, mots clés...).
- >> L’exemple suivant comporte l’ensemble des champs possibles, mais peut être allégée en fonction des besoins.
- >> Les vidéos seront alors générées avec les valeurs des champs supplémentaires telles que définies dans leur enregistreur.
- - - `RECORDER_ALLOW_INSECURE_REQUESTS` - - > valeur par défaut : `False` - - >> Autorise la requête sur l’application en elle-même sans vérifier le certificat SSL
- - - `RECORDER_BASE_URL` - - > valeur par défaut : `https://pod.univ.fr` - - >> url racine de l’instance permettant l’envoi de notification lors de la réception d’enregistrement.
- - - `RECORDER_SELF_REQUESTS_PROXIES` - - > valeur par défaut : `{"http": None, "https": None}` - - >> Précise les proxy à utiliser pour une requête vers l’application elle même dans le cadre d’enregistrement par défaut force la non utilisation de proxy.
- - - `RECORDER_SKIP_FIRST_IMAGE` - - > valeur par défaut : `False` - - >> Si True, permet de ne pas prendre en compte la 1ère image lors du traitement d’un fichier d’enregistrement de type AudioVideoCast.
- - - `RECORDER_TYPE` - - > valeur par défaut : `(('video', _('Video')), ('audiovideocast', _('Audiovideocast')), ('studio', _('Studio')))` - - >> Type d’enregistrement géré par la plateforme.
- >> Un enregistreur ne peut déposer que des fichiers de type proposé par la plateforme.
- >> Le traitement se fait en fonction du type de fichier déposé.
- - - `USE_OPENCAST_STUDIO` - - > valeur par défaut : `False` - - >> Activer l’utilisation du studio Opencast.
- - - `USE_RECORD_PREVIEW` - - > valeur par défaut : `False` - - >> Si True, affiche l’icone de prévisualisation des vidéos dans la page "Revendiquer un enregistrement".
- -### Configuration application vidéo. - - - `ACTIVE_VIDEO_COMMENT` - - > valeur par défaut : `False` - - >> Activer les commentaires au niveau de la plateforme
- - - `CELERY_BROKER_URL` - - > valeur par défaut : `amqp://pod:xxx@localhost/rabbitpod` - - >> URL de Celery pour la gestion des taches d’encodage.
- - - `CELERY_TO_ENCODE` - - > valeur par défaut : `False` - - >> Utilisation de Celery pour la gestion des taches d’encodage
- - - `CHANNEL_FORM_FIELDS_HELP_TEXT` - - > valeur par défaut : `` - - >> Ensemble des textes d’aide affichés avec le formulaire d’édition de chaine.
- >> voir pod/video/forms.py
- - - `CHUNK_SIZE` - - > valeur par défaut : `1000000` - - >> Taille d’un fragment lors de l’envoi d’une vidéo
- >> le fichier sera mis en ligne par fragment de cette taille.
- - - `CURSUS_CODES` - - > valeur par défaut : `()` - - >> Liste des cursus proposés lors de l’ajout des vidéos.
- >> Affichés en dessous d’une vidéos, ils sont aussi utilisés pour affiner la recherche.
- >> - >> ``` - >> CURSUS_CODES = ( - >> ('0', _("None / All")), - >> ('L', _("Bachelor’s Degree")), - >> ('M', _("Master’s Degree")), - >> ('D', _("Doctorate")), - >> ('1', _("Other")) - >> ) - >> - >> ``` - - - `DEFAULT_DC_COVERAGE` - - > valeur par défaut : `TITLE_ETB + " - Town - Country"` - - >> couverture du droit pour chaque vidéo
- - - `DEFAULT_DC_RIGHTS` - - > valeur par défaut : `BY-NC-SA` - - >> droit par défaut affichés dans le flux RSS si non renseigné
- - - `DEFAULT_THUMBNAIL` - - > valeur par défaut : `img/default.svg` - - >> Image par défaut affichée comme poster ou vignette, utilisée pour présenter la vidéo.
- >> Cette image doit se situer dans le répertoire static.
- - - `DEFAULT_TYPE_ID` - - > valeur par défaut : `1` - - >> Les vidéos créées sans type (par importation par exemple) seront affectées au type par défaut (en général, le type ayant pour identifiant '1' est 'Other')
- - - `DEFAULT_YEAR_DATE_DELETE` - - > valeur par défaut : `2` - - >> Durée d’obsolescence par défaut (en années après la date d’ajout).
- - - `EMAIL_ON_ENCODING_COMPLETION` - - > valeur par défaut : `True` - - >> Si True, un courriel est envoyé aux managers et à l’auteur (si DEBUG est à False) à la fin de l’encodage.
- - - `EMAIL_ON_TRANSCRIPTING_COMPLETION` - - > valeur par défaut : `True` - - >> Si True, un courriel est envoyé aux managers et à l’auteur (si DEBUG est à False) à la fin de la transcription
- - - `ENCODE_STUDIO` - - > valeur par défaut : `start_encode_studio` - - >> Fonction appelée pour lancer l’encodage du studio (merge and cut).
- - - `ENCODE_VIDEO` - - > valeur par défaut : `start_encode` - - >> Fonction appelée pour lancer l’encodage des vidéos direct par thread ou distant par celery
- - - `ENCODING_CHOICES` - - > valeur par défaut : `()` - - >> Encodage possible sur la plateforme. Associé à un rendu dans le cas d’une vidéo.
- >> - >> ``` - >> ENCODING_CHOICES = ( - >> ("audio", "audio"), - >> ("360p", "360p"), - >> ("480p", "480p"), - >> ("720p", "720p"), - >> ("1080p", "1080p"), - >> ("playlist", "playlist") - >> ) - >> - >> ``` - - - `FORCE_LOWERCASE_TAGS` - - > valeur par défaut : `True` - - >> Les mots clés saisis lors de l’ajout de vidéo sont convertis automatiquement en minuscule.
- - - `FORMAT_CHOICES` - - > valeur par défaut : `()` - - >> Format d’encodage réalisé sur la plateforme.
- >> - >> ``` - >> FORMAT_CHOICES = ( - >> ("video/mp4", "video/mp4"), - >> ("video/mp2t", "video/mp2t"), - >> ("video/webm", "video/webm"), - >> ("audio/mp3", "audio/mp3"), - >> ("audio/wav", "audio/wav"), - >> ("application/x-mpegURL", "application/x-mpegURL"), - >> ) - >> - >> ``` - - - `LANG_CHOICES` - - > valeur par défaut : `` - - >> Liste des langues proposées lors de l’ajout des vidéos.
- >> Affichés en dessous d’une vidéos, les choix sont aussi utilisés pour affiner la recherche.
- - - `LAUNCH_ENCODE_VIDEO` - - > valeur par défaut : `encode_video` - - >> Fonction appelée pour lancer l’encodage des vidéos directement (sans thread ni celery).
- - - `LICENCE_CHOICES` - - > valeur par défaut : `()` - - >> Licence proposées pour les vidéos en creative commons :
- >> - >> ``` - >> LICENCE_CHOICES = ( - >> ('by', ("Attribution 4.0 International (CC BY 4.0)")), - >> ('by-nd', ("Attribution-NoDerivatives 4.0 " - >> "International (CC BY-ND 4.0)")), - >> ('by-nc-nd', ("Attribution-NonCommercial-NoDerivatives 4.0 " - >> "International (CC BY-NC-ND 4.0)")), - >> ('by-nc', ("Attribution-NonCommercial 4.0 " - >> "International (CC BY-NC 4.0)")), - >> ('by-nc-sa', ("Attribution-NonCommercial-ShareAlike 4.0 " - >> "International (CC BY-NC-SA 4.0)")), - >> ('by-sa', ("Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)")) - >> ) - >> - >> ``` - - - `MAX_DURATION_DATE_DELETE` - - > valeur par défaut : `10` - - >> Fixe une durée maximale que la date de suppression d’une vidéo ne peut dépasser.
- >> Par défaut : 10 (Année courante + 10 ans).
- - - `MAX_TAG_LENGTH` - - > valeur par défaut : `50` - - >> Les mots clés saisis lors de l’ajout de vidéo ne peuvent dépasser cette longueur.
- - - `NOTES_STATUS` - - > valeur par défaut : `()` - - >> Valeurs possible pour l’accès à une note.
- >> - >> ``` - >> NOTES_STATUS = ( - >> ("0", _("Private (me only)")), - >> ("1", _("Shared with video owner")), - >> ("2", _("Public")), - >> ) - >> - >> ``` - - - `OEMBED` - - > valeur par défaut : `False` - - >> Permettre l’usage du oembed, partage dans Moodle, Facebook, Twitter etc.
- - - `ORGANIZE_BY_THEME` - - > valeur par défaut : `False` - - >> Affichage uniquement des vidéos de la chaîne ou du thème actuel(le).
- >> Affichage des sous-thèmes directs de la chaîne ou du thème actuel(le)
- - - `RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY` - - > valeur par défaut : `False` - - >> Si True, seule les personnes "Staff" peuvent déposer des vidéos
- - - `THEME_FORM_FIELDS_HELP_TEXT` - - > valeur par défaut : `""` - - >> Ensemble des textes d’aide affichés avec le formulaire d’édition de theme.
- >> voir pod/video/forms.py
- >> - >> ``` - >> THEME_FORM_FIELDS_HELP_TEXT = OrderedDict( - >> [ - >> ( - >> "{0}".format(_("Title field")), - >> [ - >> _( - >> "Please choose a title as short and accurate as possible, " - >> "reflecting the main subject / context of the content." - >> ), - >> _( - >> "You can use the “Description” field below for all " - >> "additional information." - >> ), - >> ], - >> ), - >> ( - >> "{0}".format(_("Description")), - >> [ - >> _( - >> "In this field you can describe your content, add all needed " - >> "related information, and format the result " - >> "using the toolbar." - >> ) - >> ], - >> ), - >> ] - >> ) - >> - >> ``` - - - `USER_VIDEO_CATEGORY` - - > valeur par défaut : `False` - - >> Permet d’activer le fonctionnement de categorie au niveau de ses vidéos.
- >> Vous pouvez créer des catégories pour pouvoir ranger vos propres vidéos.
- >> Les catégories sont liées à l’utilisateur.
- - - `USE_FAVORITES` - - > valeur par défaut : `True` - - >> Activation des vidéos favorites. Permet aux utilisateurs d'ajouter des vidéos dans leurs favoris.
- - - `USE_OBSOLESCENCE` - - > valeur par défaut : `False` - - >> Activation de l’obsolescence des video. Permet d’afficher la date de suppression de la video
- >> dans le formulaire d’edition et dans la partie admin.
- - - `USE_STATS_VIEW` - - > valeur par défaut : `False` - - >> Permet d’activer la possibilité de voir en details le nombre de visualisation d’une vidéo durant un jour donné ou mois,
- >> année ou encore le nombre de vue total depuis la création de la vidéo.
- >> Un lien est rajouté dans la partie info lors de la lecture d’une vidéo, un lien est rajouté dans la page de visualisation d’une chaîne ou un theme
- >> ou encore toutes les vidéos présentes sur la plateforme.
- - - `USE_VIDEO_EVENT_TRACKING` - - > valeur par défaut : `False` - - >> Ce paramètre permet d’activer l’envoi d’évènements sur le lecteur vidéo à Matomo.
- >> N’est utile que si le code piwik / matomo est présent dans l’instance de Esup-Pod.
- >> Les évènements envoyés sont :
- >> play, pause, seeked, ended, ratechange, fullscreen, error, loadmetadata
- >> Pour rajouter le code Piwik/Matomo dans votre instance de Pod, il suffit de créer un fichier `pod/custom/templates/custom/tracking.html`
- >> Il faut ensuite y insérer le code javascript puis dans votre fichier `settings_local.py`,
- >> de préciser dans la variable `TEMPLATE_VISIBLE_SETTINGS`: `'TRACKING_TEMPLATE': 'custom/tracking.html'`
- - - `USE_XAPI_VIDEO` - - > valeur par défaut : `False` - - - >> Active l‘envoi d’instructions xAPI pour le lecteur vidéo.
- >> Attention, il faut mettre USE_XAPI à True pour que les instructions soient envoyées.
- - - `VIDEO_ALLOWED_EXTENSIONS` - - > valeur par défaut : `()` - - >> Extensions autorisées pour le téléversement vidéo sur la plateforme (en minuscules).
- >> - >> ``` - >> VIDEO_ALLOWED_EXTENSIONS = ( - >> "3gp", - >> "avi", - >> "divx", - >> "flv", - >> "m2p", - >> "m4v", - >> "mkv", - >> "mov", - >> "mp4", - >> "mpeg", - >> "mpg", - >> "mts", - >> "wmv", - >> "mp3", - >> "ogg", - >> "wav", - >> "wma", - >> "webm", - >> "ts", - >> ) - >> - >> ``` - - - `VIDEO_FEED_NB_ITEMS` - - > valeur par défaut : `100` - - - >> nombre d'item renvoyé par le flux rss
- - - `VIDEO_FORM_FIELDS` - - > valeur par défaut : `__all__` - - >> Liste des champs du formulaire d’édition de vidéos affichées.
- - - `VIDEO_FORM_FIELDS_HELP_TEXT` - - > valeur par défaut : `` - - >> Ensemble des textes d’aide affichés avec le formulaire d’envoi de vidéo.
- >> - >> ``` - >> VIDEO_FORM_FIELDS_HELP_TEXT = OrderedDict( - >> [ - >> ( - >> "{0}".format(_("File field")), - >> [ - >> _("You can send an audio or video file."), - >> _("The following formats are supported: %s") - >> % ", ".join(map(str, VIDEO_ALLOWED_EXTENSIONS)), - >> ], - >> ), - >> ( - >> "{0}".format(_("Title field")), - >> [ - >> _( - >> "Please choose a title as short and accurate as possible, " - >> "reflecting the main subject / context of the content." - >> ), - >> _( - >> "You can use the “Description” field below for all " - >> "additional information." - >> ), - >> _( - >> "You may add contributors later using the second button of " - >> "the content edition toolbar: they will appear in the “Info” " - >> "tab at the bottom of the audio / video player." - >> ), - >> ], - >> ), - >> ( - >> "{0}".format(_("Type")), - >> [ - >> _( - >> "Select the type of your content. If the type you wish does " - >> "not appear in the list, please temporary select “Other” " - >> "and contact us to explain your needs." - >> ) - >> ], - >> ), - >> ( - >> "{0}".format(_("Additional owners")), - >> [ - >> _( - >> "In this field you can select and add additional owners to the " - >> "video. These additional owners will have the same rights as " - >> "you except that they can't delete this video." - >> ) - >> ], - >> ), - >> ( - >> "{0}".format(_("Description")), - >> [ - >> _( - >> "In this field you can describe your content, add all needed " - >> "related information, and format the result " - >> "using the toolbar." - >> ) - >> ], - >> ), - >> ( - >> "{0}".format(_("Date of the event field")), - >> [ - >> _( - >> "Enter the date of the event, if applicable, in the " - >> "AAAA-MM-JJ format." - >> ) - >> ], - >> ), - >> ( - >> "{0}".format(_("University course")), - >> [ - >> _( - >> "Select an university course as audience target of " - >> "the content." - >> ), - >> _( - >> "Choose “None / All” if it does not apply or if all are " - >> "concerned, or “Other” for an audience outside " - >> "the european LMD scheme." - >> ), - >> ], - >> ), - >> ( - >> "{0}".format(_("Main language")), - >> [_("Select the main language used in the content.")], - >> ), - >> ( - >> "{0}".format(_("Tags")), - >> [ - >> _( - >> "Please try to add only relevant keywords that can be " - >> "useful to other users." - >> ) - >> ], - >> ), - >> ( - >> "{0}".format(_("Disciplines")), - >> [ - >> _( - >> "Select the discipline to which your content belongs. " - >> "If the discipline you wish does not appear in the list, " - >> "please select nothing and contact us to explain your needs." - >> ), - >> _( - >> 'Hold down "Control", or "Command" on a Mac, ' - >> "to select more than one." - >> ), - >> ], - >> ), - >> ( - >> "{0}".format(_("Licence")), - >> [ - >> ( - >> '> 'title="%(lic)s" target="_blank">%(lic)s' - >> ) - >> % {"lic": _("Attribution 4.0 International (CC BY 4.0)")}, - >> ( - >> '> 'title="%(lic)s" target="_blank">%(lic)s' - >> ) - >> % { - >> "lic": _( - >> "Attribution-NoDerivatives 4.0 " - >> "International (CC BY-ND 4.0)" - >> ) - >> }, - >> ( - >> '> 'title="%(lic)s" target="_blank">%(lic)s' - >> ) - >> % { - >> "lic": _( - >> "Attribution-NonCommercial-NoDerivatives 4.0 " - >> "International (CC BY-NC-ND 4.0)" - >> ) - >> }, - >> ( - >> '> 'title="%(lic)s" target="_blank">%(lic)s' - >> ) - >> % { - >> "lic": _( - >> "Attribution-NonCommercial 4.0 " - >> "International (CC BY-NC 4.0)" - >> ) - >> }, - >> ( - >> '> 'title="%(lic)s" target="_blank">%(lic)s' - >> ) - >> % { - >> "lic": _( - >> "Attribution-NonCommercial-ShareAlike 4.0 " - >> "International (CC BY-NC-SA 4.0)" - >> ) - >> }, - >> ( - >> '> 'title="%(lic)s" target="_blank">%(lic)s' - >> ) - >> % { - >> "lic": _( - >> "Attribution-ShareAlike 4.0 " "International (CC BY-SA 4.0)" - >> ) - >> }, - >> ], - >> ), - >> ( - >> "{0} / {1}".format(_("Channels"), _("Themes")), - >> [ - >> _("Select the channel in which you want your content to appear."), - >> _( - >> "Themes related to this channel will " - >> "appear in the “Themes” list below." - >> ), - >> _( - >> 'Hold down "Control", or "Command" on a Mac, ' - >> "to select more than one." - >> ), - >> _( - >> "If the channel or Themes you wish does not appear " - >> "in the list, please select nothing and contact " - >> "us to explain your needs." - >> ), - >> ], - >> ), - >> ( - >> "{0}".format(_("Draft")), - >> [ - >> _( - >> "In “Draft mode”, the content shows nowhere and nobody " - >> "else but you can see it." - >> ) - >> ], - >> ), - >> ( - >> "{0}".format(_("Restricted access")), - >> [ - >> _( - >> "If you don't select “Draft mode”, you can restrict " - >> "the content access to only people who can log in" - >> ) - >> ], - >> ), - >> ( - >> "{0}".format(_("Password")), - >> [ - >> _( - >> "If you don't select “Draft mode”, you can add a password " - >> "which will be asked to anybody willing to watch " - >> "your content." - >> ), - >> _( - >> "If your video is in a playlist the password of your " - >> "video will be removed automatically." - >> ), - >> ], - >> ), - >> ] - >> ) - >> - >> ``` - - - `VIDEO_MAX_UPLOAD_SIZE` - - > valeur par défaut : `1` - - >> Taille maximum en Go des fichiers téléversés sur la plateforme.
- - - `VIDEO_PLAYBACKRATES` - - > valeur par défaut : `[0.5, 1, 1.5, 2]` - - >> Configuration des choix de vitesse de lecture pour le lecteur vidéo.
- - - `VIDEO_RECENT_VIEWCOUNT` - - > valeur par défaut : `180` - - >> Durée (en nombre de jours) sur laquelle on souhaite compter le nombre de vues récentes.
- - - `VIDEO_RENDITIONS` - - > valeur par défaut : `[]` - - >> Rendu serializé pour l’encodage des videos.
- >> Cela permet de pouvoir encoder les vidéos sans l’environnement de Pod.
- >> - >> ``` - >> VIDEO_RENDITIONS = [ - >> { - >> "resolution": "640x360", - >> "minrate": "500k", - >> "video_bitrate": "750k", - >> "maxrate": "1000k", - >> "audio_bitrate": "96k", - >> "encode_mp4": True, - >> "sites": [1], - >> },{ - >> "resolution": "1280x720", - >> "minrate": "1000k", - >> "video_bitrate": "2000k", - >> "maxrate": "3000k", - >> "audio_bitrate": "128k", - >> "encode_mp4": True, - >> "sites": [1], - >> },{ - >> "resolution": "1920x1080", - >> "minrate": "2000k", - >> "video_bitrate": "3000k", - >> "maxrate": "4500k", - >> "audio_bitrate": "192k", - >> "encode_mp4": False, - >> "sites": [1], - >> }, - >> ] - >> - >> ``` - - - `VIDEO_REQUIRED_FIELDS` - - > valeur par défaut : `[]` - - >> Permet d’ajouter l’attribut obligatoire dans le formulaire d’edition et d’ajout d’une video :
- >> Exemple de valeur : ["discipline", "tags"]
- >> NB : les champs cachés et suivant ne sont pas pris en compte :
- >> - >> `(video, title, type, owner, date_added, cursus, main_lang)` - - - `VIEW_STATS_AUTH` - - > valeur par défaut : `False` - - >> Réserve l’accès aux statistiques des vidéos aux personnes authentifiées.
- -### Configuration application search - - - `ES_INDEX` - - > valeur par défaut : `pod` - - >> Valeur pour l’index de ElasticSearch
- - - `ES_MAX_RETRIES` - - > valeur par défaut : `10` - - >> Valeur max de tentatives pour ElasticSearch.
- - - `ES_TIMEOUT` - - > valeur par défaut : `30` - - >> Valeur de timeout pour ElasticSearch.
- - - `ES_URL` - - > valeur par défaut : `["http://127.0.0.1:9200/"]` - - >> Adresse du ou des instances d’Elasticsearch utilisées pour l’indexation et la recherche de vidéo.
- - - `ES_VERSION` - - > valeur par défaut : `6` - - >> Version d’ElasticSearch.
- >> valeurs possibles 6 ou 7 (pour indiquer utiliser ElasticSearch 6 ou 7)
- >> pour utiliser la version 7, faire une mise à jour du paquet elasticsearch-py
- >> - >> `pip3 install elasticsearch==7.10.1` - >> [https://elasticsearch-py.readthedocs.io/en/v7.10.1/]() - -### Configuration application xapi - -Application pour l’envoi d‘instructions xAPI à un LRS.
-Aucune instruction ne persiste dans Pod, elles sont toutes envoyées au LRS paramétré.
-Attention, il faut configurer Celery pour l’envoi des instructions.
- - - `USE_XAPI` - - > valeur par défaut : `False` - - - >> Activation de l'application xAPI
- - - `XAPI_ANONYMIZE_ACTOR` - - > valeur par défaut : `True` - - - >> Si False, le nom de l'utilisateur sera stocké en clair dans les statements xAPI, si True, son nom d'utilisateur sera anonymisé
- - - `XAPI_LRS_LOGIN` - - > valeur par défaut : `` - - - >> identifiant de connexion du LRS pour l'envoi des statements
- - - `XAPI_LRS_PWD` - - > valeur par défaut : `` - - - >> mot de passe de connexion du LRS pour l'envoi des statements
- - - `XAPI_LRS_URL` - - > valeur par défaut : `` - - - >> URL de destination pour l'envoi des statements. I.E. : https://ralph.univ.fr/xAPI/statements
\ No newline at end of file diff --git a/Makefile b/Makefile index 066aaf9ba9..b0a275774d 100755 --- a/Makefile +++ b/Makefile @@ -90,6 +90,7 @@ createconfigs: -include .env.dev export COMPOSE = docker-compose -f ./docker-compose-dev-with-volumes.yml -p esup-pod +COMPOSE_FULL = docker-compose -f ./docker-compose-full-dev-with-volumes.yml -p esup-pod DOCKER_LOGS = docker logs -f #docker-start-build: @@ -107,6 +108,7 @@ docker-logs: echo-env: @echo ELASTICSEARCH_TAG=$(ELASTICSEARCH_TAG) @echo PYTHON_TAG=$(PYTHON_TAG) + @echo DOCKER_ENV=$(DOCKER_ENV) # Démarre le serveur de test en recompilant les conteneurs de la stack docker-build: @@ -118,25 +120,41 @@ docker-build: sudo rm -rf ./pod/static # sudo rm -rf ./pod/node_modules sudo rm -rf ./pod/node_modules +ifeq ($(DOCKER_ENV), full) + @$(COMPOSE_FULL) build --build-arg ELASTICSEARCH_VERSION=$(ELASTICSEARCH_TAG) --build-arg NODE_VERSION=$(NODE_TAG) --build-arg PYTHON_VERSION=$(PYTHON_TAG) --no-cache + @$(COMPOSE_FULL) up +else @$(COMPOSE) build --build-arg ELASTICSEARCH_VERSION=$(ELASTICSEARCH_TAG) --build-arg NODE_VERSION=$(NODE_TAG) --build-arg PYTHON_VERSION=$(PYTHON_TAG) --no-cache @$(COMPOSE) up +endif # Vous devriez obtenir ce message une fois esup-pod lancé # $ pod-dev-with-volumes | Superuser created successfully. # Démarre le serveur de test docker-start: # (Attention, il a été constaté que sur un mac, le premier lancement peut prendre plus de 5 minutes.) +ifeq ($(DOCKER_ENV), full) + @$(COMPOSE_FULL) up +else @$(COMPOSE) up +endif # Vous devriez obtenir ce message une fois esup-pod lancé # $ pod-dev-with-volumes | Superuser created successfully. # Arrête le serveur de test docker-stop: +ifeq ($(DOCKER_ENV), full) + @$(COMPOSE_FULL) down -v +else @$(COMPOSE) down -v - +endif # Arrête le serveur de test et supprime les fichiers générés docker-reset: +ifeq ($(DOCKER_ENV), full) + @$(COMPOSE_FULL) down -v +else @$(COMPOSE) down -v +endif # sudo rm -rf ./pod/log sudo rm -rf ./pod/log # sudo rm -rf ./pod/static diff --git a/README.md b/README.md index 8236c551d7..aaad9b9ed7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Créé en 2014, le projet Pod a connu de nombreux changement ces dernières années. Initié à l’[Université de Lille](https://www.univ-lille.fr/), il est depuis septembre 2015 piloté par le consortium [Esup Portail](https://www.esup-portail.org/) et soutenu également par le [Ministère de l’Enseignement supérieur, de la Recherche et de l’Innovation](http://www.enseignementsup-recherche.gouv.fr/). -Le projet et la plateforme qui porte le même nom ont pour but de faciliter la mise à disposition de vidéo et de ce fait, d’encourager l’utilisation de celles-ci dans le cadre de l’enseignement et la recherche. +Le projet et la plateforme qui porte le même nom ont pour but de faciliter la mise à disposition de vidéos et de ce fait, d’encourager l’utilisation de celles-ci dans le cadre de l’enseignement et la recherche. ### Documentation technique * Accédez à toute la documentation (installation, paramétrage etc.) [sur notre wiki](https://www.esup-portail.org/wiki/display/ES/esup-pod "Documentation technique") diff --git a/docker-compose-dev-with-volumes.yml b/docker-compose-dev-with-volumes.yml index 7e9efbebb0..9eb189ed34 100755 --- a/docker-compose-dev-with-volumes.yml +++ b/docker-compose-dev-with-volumes.yml @@ -40,4 +40,14 @@ services: env_file: - ./.env.dev ports: - - 6379:6379 \ No newline at end of file + - 6379:6379 + +# redis-commander: +# container_name: redis-commander +# hostname: redis-commander +# image: rediscommander/redis-commander:latest +# restart: always +# environment: +# - REDIS_HOSTS=local:redis:6379 +# ports: +# - "8081:8081" diff --git a/docker-compose-full-dev-with-volumes.yml b/docker-compose-full-dev-with-volumes.yml new file mode 100755 index 0000000000..52977c66a5 --- /dev/null +++ b/docker-compose-full-dev-with-volumes.yml @@ -0,0 +1,86 @@ +x-pod-volumes: &pod-volumes + - .:/usr/src/app + +x-elasticsearch-volumes: &elasticsearch-volumes + - ./dockerfile-dev-with-volumes/config/elasticsearch/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml + +version: '3.7' + +services: + pod-back: + container_name: pod-back-with-volumes + build: + context: . + dockerfile: dockerfile-dev-with-volumes/pod-back/Dockerfile + depends_on: + - elasticsearch + - redis + env_file: + - ./.env.dev + ports: + - 9090:8080 + volumes: *pod-volumes + + pod-encode: + container_name: pod-encode-with-volumes + build: + context: . + dockerfile: dockerfile-dev-with-volumes/pod-encode/Dockerfile + depends_on: + - pod-back + env_file: + - ./.env.dev + volumes: *pod-volumes + + pod-transcript: + container_name: pod-transcript-with-volumes + build: + context: . + dockerfile: dockerfile-dev-with-volumes/pod-transcript/Dockerfile + depends_on: + - pod-back + env_file: + - ./.env.dev + volumes: *pod-volumes + + pod-xapi: + container_name: pod-xapi-with-volumes + build: + context: . + dockerfile: dockerfile-dev-with-volumes/pod-xapi/Dockerfile + depends_on: + - pod-back + env_file: + - ./.env.dev + volumes: *pod-volumes + + elasticsearch: + container_name: elasticsearch-with-volumes + build: + context: . + dockerfile: dockerfile-dev-with-volumes/elasticsearch/dockerfile-elasticsearch-dev + ports: + - 9200:9200 + environment: + - discovery.type=single-node + - bootstrap.memory_lock=true + - "ES_JAVA_OPTS=-Xms2g -Xmx2g" + volumes: *elasticsearch-volumes + + redis: + container_name: redis-with-volumes + image: ${REDIS_TAG} + env_file: + - ./.env.dev + ports: + - 6379:6379 + +# redis-commander: +# container_name: redis-commander +# hostname: redis-commander +# image: rediscommander/redis-commander:latest +# restart: always +# environment: +# - REDIS_HOSTS=local:redis:6379 +# ports: +# - "8081:8081" diff --git a/dockerfile-dev-with-volumes/README.adoc b/dockerfile-dev-with-volumes/README.adoc index 4ecf9aeccd..a902038a23 100755 --- a/dockerfile-dev-with-volumes/README.adoc +++ b/dockerfile-dev-with-volumes/README.adoc @@ -1,6 +1,6 @@ = Docker -Nom de l’auteur -v1.1, 2023-02-28 +Nom de l’auteur +v1.2, 2023-08-30 :toc: :toc-title: Liste des rubriques :imagesdir: ./images @@ -10,14 +10,7 @@ v1.1, 2023-02-28 === Conteneur ElasticSearch http://localhost:9200 -==== elasticsearch:6.8.23 -===== OS/ARCH ----- -OS/ARCH -linux/amd64 ----- - -==== elasticsearch:7.17.7 +==== elasticsearch:8.8.1 ===== OS/ARCH ---- OS/ARCH @@ -48,32 +41,33 @@ linux/arm64/v8 ----- ===== Configuration -. Renommer le fichier .env.dev-exemple en .env.dev -. Renseigner le fichier .env.dev comme ceci (attention, changer les valeurs de username, password et email): +. Renommer le fichier .env.dev-exemple en .env.dev et le renseigner. +. Vous devez changer les valeurs d'identifiant, mot de passe et courriel. +. Pour la variable DOCKER_ENV, vous pouvez choisir entre *light* (1 docker pour Pod avec que l'encodage d'activé) ou *full* (4 docker pour Pod : pod-back, encodage, transcription et xAPI) + [source,shell] ---- DJANGO_SUPERUSER_USERNAME= DJANGO_SUPERUSER_PASSWORD= DJANGO_SUPERUSER_EMAIL= -ELASTICSEARCH_TAG=elasticsearch:7.17.7 +ELASTICSEARCH_TAG=elasticsearch:8.8.1 NODE_TAG=node:19 -PYTHON_TAG=python:3.7-buster +PYTHON_TAG=python:3.9-buster REDIS_TAG=redis:alpine3.16 +DOCKER_ENV=light ---- . Créer un fichier pod/custom/settings_local.py Renseignez le fichier pod/custom/settings_local.py comme ceci : [source,python] ---- -# Si ElasticSearch 7 + USE_PODFILE = True EMAIL_ON_ENCODING_COMPLETION = False SECRET_KEY = "A_CHANGER" DEBUG = True - -ES_VERSION = 7 - +# on précise ici qu'on utilise ES version 8 +ES_VERSION = 8 ES_URL = ['http://elasticsearch:9200/'] @@ -106,6 +100,21 @@ SESSION_REDIS = { # Uniquement lors d’environnement conteneurisé MIGRATION_MODULES = {'flatpages': 'pod.db_migrations'} + +# Si DOCKER_ENV = full il faut activer l'encodage et la transcription distante +# USE_DISTANT_ENCODING_TRANSCODING = True +# ENCODING_TRANSCODING_CELERY_BROKER_URL = "redis://redis:6379/7" + +# pour avoir le maximum de log sur la console +LOGGING = {} + +# PUSH NOTIFICATIONS +# Les clés VAPID peuvent être générées avec https://web-push-codelab.glitch.me/ +WEBPUSH_SETTINGS = { + "VAPID_PUBLIC_KEY": "", + "VAPID_PRIVATE_KEY": "", + "VAPID_ADMIN_EMAIL": "contact@example.org" +} ---- == Commandes diff --git a/dockerfile-dev-with-volumes/pod-back/Dockerfile b/dockerfile-dev-with-volumes/pod-back/Dockerfile new file mode 100755 index 0000000000..e0d9013d7d --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-back/Dockerfile @@ -0,0 +1,41 @@ +#------------------------------------------------------------------------------------------------------------------------------ +# (\___/) +# (='.'=) Dockerfile multi-stages node & python +# (")_(") +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur node +ARG NODE_VERSION +ARG PYTHON_VERSION +FROM $NODE_VERSION as source-build-js +# TODO +#FROM harbor.urba.univ-lille.fr/store/node:19 as source-build-js + +WORKDIR /tmp/pod +COPY ./pod/ . +RUN yarn +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur python +FROM $PYTHON_VERSION +# TODO +#FROM harbor.urba.univ-lille.fr/store/python:3.7-buster + +RUN apt-get clean && apt-get update && apt-get install -y netcat + +WORKDIR /usr/src/app + +COPY ./requirements.txt . +COPY ./requirements-encode.txt . +COPY ./requirements-conteneur.txt . +COPY ./requirements-dev.txt . +RUN mkdir /tmp/node_modules/ + +COPY --from=source-build-js /tmp/pod/node_modules/ /tmp/node_modules/ +# TODO remove ES version - move it into env var +RUN pip3 install --no-cache-dir -r requirements-conteneur.txt \ + && pip3 install elasticsearch==8.9.0 + +# ENTRYPOINT : +COPY ./dockerfile-dev-with-volumes/pod-back/my-entrypoint-back.sh /tmp/my-entrypoint-back.sh +RUN chmod 755 /tmp/my-entrypoint-back.sh + +ENTRYPOINT ["bash", "/tmp/my-entrypoint-back.sh"] \ No newline at end of file diff --git a/dockerfile-dev-with-volumes/pod-back/my-entrypoint-back.sh b/dockerfile-dev-with-volumes/pod-back/my-entrypoint-back.sh new file mode 100644 index 0000000000..5e785f880c --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-back/my-entrypoint-back.sh @@ -0,0 +1,31 @@ +#!/bin/sh +echo "Launching commands into pod-dev" +mkdir -p pod/node_modules +mkdir -p pod/db_migrations && touch pod/db_migrations/__init__.py +ln -fs /tmp/node_modules/* pod/node_modules +until nc -z elasticsearch 9200; do echo waiting for elasticsearch; sleep 10; done; +# Mise en route +# Base de données SQLite intégrée +BDD_FILE=/usr/src/app/pod/db.sqlite3 +if test ! -f "$BDD_FILE"; then + echo "$BDD_FILE does not exist." + python3 manage.py create_pod_index + curl -XGET "elasticsearch:9200/pod/_search" + # Deployez les fichiers statiques + python3 manage.py collectstatic --no-input --clear + # Lancez le script présent à la racine afin de créer les fichiers de migration, puis de les lancer pour créer la base de données SQLite intégrée. + make createDB + # SuperUtilisateur + # Il faut créer un premier utilisateur qui aura tous les pouvoirs sur votre instance. + python3 manage.py createsuperuser --noinput +else + echo "$BDD_FILE exist." +fi +# Serveur de développement +# Le serveur de développement permet de tester vos futures modifications facilement. +# N'hésitez pas à lancer le serveur de développement pour vérifier vos modifications au fur et à mesure. +# À ce niveau, vous devriez avoir le site en français et en anglais et voir l'ensemble de la page d'accueil. +celery -A pod.video_encode_transcript.importing_tasks worker -l INFO -Q importing --concurrency 1 --detach -n import_encode +celery -A pod.video_encode_transcript.importing_transcript_tasks worker -l INFO -Q importing_transcript --concurrency 1 --detach -n import_transcript +python3 manage.py runserver 0.0.0.0:8080 --insecure +sleep infinity diff --git a/dockerfile-dev-with-volumes/pod-encode/Dockerfile b/dockerfile-dev-with-volumes/pod-encode/Dockerfile new file mode 100755 index 0000000000..c29e44ad54 --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-encode/Dockerfile @@ -0,0 +1,35 @@ +#------------------------------------------------------------------------------------------------------------------------------ +# (\___/) +# (='.'=) Dockerfile multi-stages node & python +# (")_(") +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur node +ARG PYTHON_VERSION +# TODO +#FROM harbor.urba.univ-lille.fr/store/node:19 as source-build-js + +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur python +FROM $PYTHON_VERSION +WORKDIR /tmp/pod +COPY ./pod/ . +# TODO +#FROM harbor.urba.univ-lille.fr/store/python:3.7-buster + +RUN apt-get clean && apt-get update \ + && apt-get install -y netcat \ + ffmpeg \ + ffmpegthumbnailer \ + imagemagick + +WORKDIR /usr/src/app + +COPY ./requirements-encode.txt . + +RUN pip3 install --no-cache-dir -r requirements-encode.txt + +# ENTRYPOINT : +COPY ./dockerfile-dev-with-volumes/pod-encode/my-entrypoint-encode.sh /tmp/my-entrypoint-encode.sh +RUN chmod 755 /tmp/my-entrypoint-encode.sh + +ENTRYPOINT ["bash", "/tmp/my-entrypoint-encode.sh"] \ No newline at end of file diff --git a/dockerfile-dev-with-volumes/pod-encode/my-entrypoint-encode.sh b/dockerfile-dev-with-volumes/pod-encode/my-entrypoint-encode.sh new file mode 100644 index 0000000000..df727a4b14 --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-encode/my-entrypoint-encode.sh @@ -0,0 +1,6 @@ +#!/bin/sh +echo "Launching commands into pod-dev" +until nc -z pod-back 8080; do echo waiting for pod-back; sleep 10; done; +# Serveur d'encodage +celery -A pod.video_encode_transcript.encoding_tasks worker -l INFO -Q encoding --concurrency 1 -n encode +sleep infinity diff --git a/dockerfile-dev-with-volumes/pod-transcript/Dockerfile b/dockerfile-dev-with-volumes/pod-transcript/Dockerfile new file mode 100755 index 0000000000..71b1ca3f52 --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-transcript/Dockerfile @@ -0,0 +1,36 @@ +#------------------------------------------------------------------------------------------------------------------------------ +# (\___/) +# (='.'=) Dockerfile multi-stages node & python +# (")_(") +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur node +ARG PYTHON_VERSION +# TODO +#FROM harbor.urba.univ-lille.fr/store/node:19 as source-build-js + +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur python +FROM $PYTHON_VERSION +WORKDIR /tmp/pod +COPY ./pod/ . +# TODO +#FROM harbor.urba.univ-lille.fr/store/python:3.7-buster + +RUN apt-get clean && apt-get update \ + && apt-get install -y netcat \ + sox \ + libsox-fmt-mp3 + +WORKDIR /usr/src/app + +COPY ./requirements-encode.txt . +COPY ./requirements-transcripts.txt . + +RUN pip3 install --no-cache-dir -r requirements-transcripts.txt \ + && pip3 install --no-cache-dir -r requirements-encode.txt + +# ENTRYPOINT : +COPY ./dockerfile-dev-with-volumes/pod-transcript/my-entrypoint-transcript.sh /tmp/my-entrypoint-transcript.sh +RUN chmod 755 /tmp/my-entrypoint-transcript.sh + +ENTRYPOINT ["bash", "/tmp/my-entrypoint-transcript.sh"] \ No newline at end of file diff --git a/dockerfile-dev-with-volumes/pod-transcript/my-entrypoint-transcript.sh b/dockerfile-dev-with-volumes/pod-transcript/my-entrypoint-transcript.sh new file mode 100644 index 0000000000..cdb2b1fe78 --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-transcript/my-entrypoint-transcript.sh @@ -0,0 +1,6 @@ +#!/bin/sh +echo "Launching commands into pod-dev" +until nc -z pod-back 8080; do echo waiting for pod-back; sleep 10; done; +# Serveur d'encodage +celery -A pod.video_encode_transcript.transcripting_tasks worker -l INFO -Q transcripting --concurrency 1 -n transcript +sleep infinity diff --git a/dockerfile-dev-with-volumes/pod-xapi/Dockerfile b/dockerfile-dev-with-volumes/pod-xapi/Dockerfile new file mode 100755 index 0000000000..a8e22be23c --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-xapi/Dockerfile @@ -0,0 +1,31 @@ +#------------------------------------------------------------------------------------------------------------------------------ +# (\___/) +# (='.'=) Dockerfile multi-stages node & python +# (")_(") +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur node +ARG PYTHON_VERSION +# TODO +#FROM harbor.urba.univ-lille.fr/store/node:19 as source-build-js + +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur python +FROM $PYTHON_VERSION +WORKDIR /tmp/pod +COPY ./pod/ . +# TODO +#FROM harbor.urba.univ-lille.fr/store/python:3.7-buster + +RUN apt-get clean && apt-get update && apt-get install -y netcat + +WORKDIR /usr/src/app + +COPY ./requirements-encode.txt . + +RUN pip3 install --no-cache-dir -r requirements-encode.txt + +# ENTRYPOINT : +COPY ./dockerfile-dev-with-volumes/pod-xapi/my-entrypoint-xapi.sh /tmp/my-entrypoint-xapi.sh +RUN chmod 755 /tmp/my-entrypoint-xapi.sh + +ENTRYPOINT ["bash", "/tmp/my-entrypoint-xapi.sh"] \ No newline at end of file diff --git a/dockerfile-dev-with-volumes/pod-xapi/my-entrypoint-xapi.sh b/dockerfile-dev-with-volumes/pod-xapi/my-entrypoint-xapi.sh new file mode 100644 index 0000000000..964472a521 --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-xapi/my-entrypoint-xapi.sh @@ -0,0 +1,6 @@ +#!/bin/sh +echo "Launching commands into pod-dev" +until nc -z pod-back 8080; do echo waiting for pod-back; sleep 10; done; +# Serveur xAPI +celery -A pod.xapi.xapi_tasks worker -l INFO -Q xapi --concurrency 1 -n xapi +sleep infinity diff --git a/dockerfile-dev-with-volumes/pod/Dockerfile b/dockerfile-dev-with-volumes/pod/Dockerfile index 755e9ad3b2..68f8d08065 100755 --- a/dockerfile-dev-with-volumes/pod/Dockerfile +++ b/dockerfile-dev-with-volumes/pod/Dockerfile @@ -19,7 +19,7 @@ FROM $PYTHON_VERSION # TODO #FROM harbor.urba.univ-lille.fr/store/python:3.7-buster -RUN apt-get update \ +RUN apt-get clean && apt-get update \ && apt-get install -y netcat \ ffmpeg \ ffmpegthumbnailer \ @@ -29,16 +29,17 @@ WORKDIR /usr/src/app COPY ./requirements.txt . COPY ./requirements-conteneur.txt . +COPY ./requirements-encode.txt . COPY ./requirements-dev.txt . RUN mkdir /tmp/node_modules/ COPY --from=source-build-js /tmp/pod/node_modules/ /tmp/node_modules/ RUN pip3 install --no-cache-dir -r requirements-conteneur.txt \ - && pip3 install elasticsearch==7.17.7 + && pip3 install elasticsearch==8.9.0 # ENTRYPOINT : COPY ./dockerfile-dev-with-volumes/pod/my-entrypoint.sh /tmp/my-entrypoint.sh RUN chmod 755 /tmp/my-entrypoint.sh -ENTRYPOINT ["bash", "/tmp/my-entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["bash", "/tmp/my-entrypoint.sh"] diff --git a/make.bat b/make.bat index 7c32f3e558..06872ef1e4 100644 --- a/make.bat +++ b/make.bat @@ -43,4 +43,3 @@ if /i "%1"=="docker-build" ( ) else ( echo "Mauvaise syntaxe" ) - diff --git a/pod/authentication/forms.py b/pod/authentication/forms.py index 1a917c0a1b..e920bae91d 100644 --- a/pod/authentication/forms.py +++ b/pod/authentication/forms.py @@ -49,6 +49,17 @@ class Meta(object): fields = [] +class SetNotificationForm(forms.ModelForm): + """Push notification preferences form.""" + + def __init__(self, *args, **kwargs): + super(SetNotificationForm, self).__init__(*args, **kwargs) + + class Meta(object): + model = Owner + fields = ["accepts_notifications"] + + User = get_user_model() diff --git a/pod/authentication/models.py b/pod/authentication/models.py index 4e5abdae1d..1cd8c126fd 100644 --- a/pod/authentication/models.py +++ b/pod/authentication/models.py @@ -64,10 +64,16 @@ FILES_DIR = getattr(settings, "FILES_DIR", "files") -def get_name(self): - if HIDE_USERNAME: - return "%s %s" % (self.first_name, self.last_name) - return "%s %s (%s)" % (self.first_name, self.last_name, self.username) +def get_name(self) -> str: + """ + Returns the user's full name, including the username if not hidden. + + Returns: + str: The user's full name and username if not hidden. + """ + if HIDE_USERNAME or not self.is_authenticated: + return self.get_full_name().strip() + return f"{self.get_full_name()} ({self.get_username()})".strip() User.add_to_class("__str__", get_name) @@ -99,6 +105,12 @@ class Owner(models.Model): ) accessgroups = models.ManyToManyField("authentication.AccessGroup", blank=True) sites = models.ManyToManyField(Site) + accepts_notifications = models.BooleanField( + verbose_name=_("Accept notifications"), + default=None, + null=True, + help_text=_("Receive push notifications on your devices."), + ) class Meta: verbose_name = _("Owner") diff --git a/pod/authentication/tests/test_populated.py b/pod/authentication/tests/test_populated.py index 996ee8f02d..86279388af 100644 --- a/pod/authentication/tests/test_populated.py +++ b/pod/authentication/tests/test_populated.py @@ -474,7 +474,7 @@ def _authenticate_shib_user(self, u): @override_settings(DEBUG=False) def test_make_profile(self): - """Test if user attributes are retreived""" + """Test if user attributes are retrieved.""" user, shib_meta = self._authenticate_shib_user( { "username": "jdo@univ.fr", diff --git a/pod/bbb/templates/bbb/live_publish_meeting.html b/pod/bbb/templates/bbb/live_publish_meeting.html index 8b3b7ef031..fe25166ef0 100644 --- a/pod/bbb/templates/bbb/live_publish_meeting.html +++ b/pod/bbb/templates/bbb/live_publish_meeting.html @@ -55,7 +55,7 @@

{% trans "Are you sure you want to perform a BigBlueButton live?" %}

{% endif %} {% for field in form.visible_fields %} {% spaceless %} -
+
{{ field.errors }} diff --git a/pod/bbb/templates/bbb/publish_meeting.html b/pod/bbb/templates/bbb/publish_meeting.html index 65f4c8627f..d873c9cdea 100644 --- a/pod/bbb/templates/bbb/publish_meeting.html +++ b/pod/bbb/templates/bbb/publish_meeting.html @@ -47,7 +47,7 @@

{% trans "Are you sure you want to publish this BigBlueButton presentation?" {% endif %} {% for field in form.visible_fields %} {% spaceless %} -
+
{{ field.errors }} diff --git a/pod/chapter/forms.py b/pod/chapter/forms.py index b76ea4120f..3b45d60c95 100644 --- a/pod/chapter/forms.py +++ b/pod/chapter/forms.py @@ -1,3 +1,4 @@ +"""Forms to create/edit and import Esup-Pod video chapter.""" from django import forms from django.conf import settings from django.core.exceptions import ValidationError @@ -5,7 +6,7 @@ from pod.chapter.models import Chapter from pod.chapter.utils import vtt_to_chapter -from pod.main.forms_utils import add_placeholder_and_asterisk +from pod.main.forms_utils import add_placeholder_and_asterisk, add_describedby_attr if getattr(settings, "USE_PODFILE", False): __FILEPICKER__ = True @@ -17,7 +18,10 @@ class ChapterForm(forms.ModelForm): + """A form to create/edit a video chapter.""" + def __init__(self, *args, **kwargs): + """Initialize fields.""" super(ChapterForm, self).__init__(*args, **kwargs) self.fields["video"].widget = forms.HiddenInput() self.fields["time_start"].widget.attrs["min"] = 0 @@ -28,16 +32,22 @@ def __init__(self, *args, **kwargs): except Exception: self.fields["time_start"].widget.attrs["max"] = 36000 self.fields = add_placeholder_and_asterisk(self.fields) + self.fields = add_describedby_attr(self.fields) class Meta: + """Form Metadata.""" + model = Chapter fields = "__all__" class ChapterImportForm(forms.Form): + """A form to import chapters from VTT file.""" + file = forms.ModelChoiceField(queryset=CustomFileModel.objects.all()) def __init__(self, *args, **kwargs): + """Initialize fields.""" self.user = kwargs.pop("user") self.video = kwargs.pop("video") super(ChapterImportForm, self).__init__(*args, **kwargs) @@ -48,10 +58,14 @@ def __init__(self, *args, **kwargs): ) else: self.fields["file"].queryset = CustomFileModel.objects.all() + # self.fields = add_placeholder_and_asterisk(self.fields) + self.fields = add_describedby_attr(self.fields) self.fields["file"].label = _("File to import") + self.fields["file"].help_text = _("The file must be in VTT format.") def clean_file(self): + """Convert VTT to chapters and return cleaned Data.""" msg = vtt_to_chapter(self.cleaned_data["file"], self.video) if msg: - raise ValidationError("Error ! {0}".format(msg)) + raise ValidationError("Error! {0}".format(msg)) return self.cleaned_data["file"] diff --git a/pod/chapter/models.py b/pod/chapter/models.py index 3151fa8e04..1643bc3779 100644 --- a/pod/chapter/models.py +++ b/pod/chapter/models.py @@ -60,6 +60,7 @@ def verify_title_items(self): return list() def verify_time(self): + """Check that start time is included inside video duration.""" msg = list() if ( self.time_start == "" diff --git a/pod/chapter/static/css/videojs-chapters.css b/pod/chapter/static/css/videojs-chapters.css index 4ca6197bf0..f8d2d1c38e 100644 --- a/pod/chapter/static/css/videojs-chapters.css +++ b/pod/chapter/static/css/videojs-chapters.css @@ -1,63 +1,72 @@ .chapters-list.inactive, .chapters-list.active { - position: absolute; - right: 0; - top: 0; - height: 100%; - width: 20%; - background-color: rgba(43,51,63,.7); - overflow-y: auto; - /*border: 2px solid black;*/ - cursor: default; - z-index: 2; + position: absolute; + right: 0; + top: 0; + height: 100%; + width: 20%; + background-color: rgba(43, 51, 63, .7); + overflow-y: auto; + /*border: 2px solid black;*/ + cursor: default; + z-index: 2; } + .chapters-list.inactive { - display: none; + display: none; } + .chapters-list.active { - display: block; + display: block; } + .chapters-list h6 { - text-align: center; + text-align: center; } + .chapters-list.active p, .chapters-list.inactive p { - text-align: center; - background-color: #000; - color: #fff; - padding-bottom: 5px; - padding-top: 5px; + text-align: center; + background-color: #000; + color: #fff; + padding-bottom: 5px; + padding-top: 5px; } + .chapters-list ol { - position: relative; - margin: 0; - padding: 0; - padding-bottom: 30px; + position: relative; + margin: 0; + padding: 0; + padding-bottom: 30px; } + .chapters-list ol li { - display: list-item; - list-style-type: none; - text-align: -webkit-match-parent; - /*background-color: rgba(0, 0, 0, 0.8);*/ - margin: 0 3px; - border-top:1px solid rgba(0, 0, 0, 0.8); + display: list-item; + list-style-type: none; + text-align: -webkit-match-parent; + /*background-color: rgba(0, 0, 0, 0.8);*/ + margin: 0 3px; + border-top: 1px solid rgba(0, 0, 0, .8); } + .chapters-list ol li a:hover, +.chapters-list ol li a:focus, .chapters-list ol li a.current { - background-color: #555; - color: white; - cursor: pointer; + background-color: #555; + color: white; + cursor: pointer; } + .chapters-list ol li a { - display: block; - padding: .7rem 1rem; - transition: .3s; - color: #fff; - text-align: left; - font-size:1.3em; + display: block; + padding: .7rem 1rem; + transition: .3s; + color: #fff; + text-align: left; + font-size: 1.3em; } #chapters { - display: none; -} \ No newline at end of file + display: none; +} diff --git a/pod/chapter/static/js/chapters.js b/pod/chapter/static/js/chapters.js index 75a97e813f..1b3bcace05 100644 --- a/pod/chapter/static/js/chapters.js +++ b/pod/chapter/static/js/chapters.js @@ -16,14 +16,23 @@ function show_form(data) { }); fadeIn(form_chapter); + var describedby_list = []; let inputStart = document.getElementById("id_time_start"); if (inputStart) { inputStart.insertAdjacentHTML( "beforebegin", - " " + + " " + gettext("Get time from the player") + - " " + " ", ); + + if (inputStart.getAttribute("aria-describedby")) { + describedby_list = inputStart.getAttribute("aria-describedby").split(" "); + } + if (describedby_list.indexOf("chapter_time_start") === -1) { + describedby_list.push("chapter_time_start"); + } + inputStart.setAttribute("aria-describedby", describedby_list.join(" ")); } let inputEnd = document.getElementById("id_time_end"); if (inputEnd) { @@ -31,8 +40,15 @@ function show_form(data) { "beforebegin", " " + gettext("Get time from the player") + - " " + " ", ); + if (inputEnd.getAttribute("aria-describedby")) { + describedby_list = inputEnd.getAttribute("aria-describedby").split(" "); + } + if (describedby_list.indexOf("chapter_time_end") === -1) { + describedby_list.push("chapter_time_end"); + } + inputEnd.setAttribute("aria-describedby", describedby_list.join(" ")); } } @@ -42,9 +58,7 @@ var showalert = function (message, alerttype) { alerttype + ' alert-dismissible fade show" role="alert">' + message + - '
' + '
', ); setTimeout(function () { document.getElementById("formalertdiv").remove(); @@ -58,7 +72,7 @@ var ajaxfail = function (data) { data + ") " + gettext("The form could not be recovered."), - "alert-danger" + "alert-danger", ); show_form(""); }; @@ -111,7 +125,7 @@ var sendandgetform = async function (elt) { ) { showalert( gettext("You are no longer authenticated. Please log in again."), - "alert-danger" + "alert-danger", ); return; } @@ -125,7 +139,7 @@ var sendandgetform = async function (elt) { if (data.indexOf("list_chapter") == -1) { showalert( gettext("You are no longer authenticated. Please log in again."), - "alert-danger" + "alert-danger", ); return; } @@ -157,19 +171,19 @@ var sendform = async function (elt, action) { form_save.style.display = "none"; data_form = new FormData(form_save); validationMessage = gettext( - "Make sure your chapter start time is not 0 or equal to another chapter start time." + "Make sure your chapter start time is not 0 or equal to another chapter start time.", ); } else { showalert( gettext("One or more errors have been found in the form."), - "alert-danger" + "alert-danger", ); return; } } else if (action === "import") { data_form = new FormData(elt); validationMessage = gettext( - "Make sure you added a file and that it is a valid file." + "Make sure you added a file and that it is a valid file.", ); } else { // if action is neither "save" nor "import", show an error and return @@ -187,7 +201,7 @@ var sendform = async function (elt, action) { if (data.indexOf("list_chapter") == -1 && data.indexOf("form") == -1) { showalert( gettext("You are no longer authenticated. Please log in again."), - "alert-danger" + "alert-danger", ); } else { const jsonData = JSON.parse(data); @@ -210,53 +224,93 @@ var sendform = async function (elt, action) { /*** Verify if value of field respect form field ***/ function verify_start_title_items() { + var ret = true; + + // First, check Title field. + let inputTitle = document.getElementById("id_title"); + inputTitle.classList.remove("is-invalid"); + inputTitle.classList.remove("is-valid"); + var describedby_list = []; + if (inputTitle.getAttribute("aria-describedby")) { + describedby_list = inputTitle.getAttribute("aria-describedby").split(" "); + } + + var errormsg_id = "lengthErrorMsg"; if ( inputTitle.value === "" || inputTitle.value.length < 2 || inputTitle.value.length > 100 ) { - if (typeof lengthErrorSpan === "undefined") { - lengthErrorSpan = document.createElement("span"); - lengthErrorSpan.className = "form-help-inline"; - lengthErrorSpan.innerHTML = - "  " + - gettext("Please enter a title from 2 to 100 characters."); - inputTitle.insertAdjacentHTML("beforebegin", lengthErrorSpan.outerHTML); + if (typeof lengthErrorMsg === "undefined") { + lengthErrorMsg = document.createElement("div"); + lengthErrorMsg.id = errormsg_id; + lengthErrorMsg.className = "invalid-feedback"; + lengthErrorMsg.innerHTML = gettext( + "Please enter a title from 2 to 100 characters.", + ); + inputTitle.insertAdjacentHTML("afterend", lengthErrorMsg.outerHTML); inputTitle.parentNode.parentNode .querySelectorAll("div.form-group") .forEach(function (elt) { elt.classList.add("has-error"); }); } - return false; + if (describedby_list.indexOf(errormsg_id) === -1) { + describedby_list.push(errormsg_id); + } + inputTitle.classList.add("is-invalid"); + ret = false; + } else { + describedby_list.pop(errormsg_id); + inputTitle.classList.add("is-valid"); } + inputTitle.setAttribute("aria-describedby", describedby_list.join(" ")); + + // Then check inputStart field. let inputStart = document.getElementById("id_time_start"); + inputStart.classList.remove("is-invalid"); + inputStart.classList.remove("is-valid"); + + errormsg_id = "timeErrorMsg"; + if (inputStart.getAttribute("aria-describedby")) { + describedby_list = inputStart.getAttribute("aria-describedby").split(" "); + } else { + describedby_list = []; + } if ( inputStart.value === "" || inputStart.value < 0 || inputStart.value >= video_duration ) { - if (typeof timeErrorSpan === "undefined") { - timeErrorSpan = document.createElement("span"); - timeErrorSpan.className = "form-help-inline"; - timeErrorSpan.innerHTML = - "  " + + if (typeof timeErrorMsg === "undefined") { + timeErrorMsg = document.createElement("div"); + timeErrorMsg.id = errormsg_id; + timeErrorMsg.className = "invalid-feedback"; + timeErrorMsg.innerHTML = gettext("Please enter a correct start field between 0 and") + " " + (video_duration - 1); - inputStart.insertAdjacentHTML("beforebegin", timeErrorSpan.outerHTML); + inputStart.insertAdjacentHTML("afterend", timeErrorMsg.outerHTML); + inputStart.setAttribute("aria-describedby", errormsg_id); inputStart.parentNode.parentNode .querySelectorAll("div.form-group") .forEach(function (elt) { elt.classList.add("has-error"); }); } - return false; + inputStart.classList.add("is-invalid"); + if (describedby_list.indexOf(errormsg_id) === -1) { + describedby_list.push(errormsg_id); + } + ret = false; + } else { + inputStart.classList.add("is-valid"); + describedby_list.pop(errormsg_id); } - timeErrorSpan = undefined; - lengthErrorSpan = undefined; - return true; + inputStart.setAttribute("aria-describedby", describedby_list.join(" ")); + + return ret; } function overlaptest() { @@ -318,7 +372,7 @@ document.addEventListener("click", (event) => { if (!(typeof player === "undefined")) { if (event.target.matches("#getfromvideo_start")) { document.getElementById("id_time_start").value = Math.floor( - player.currentTime() + player.currentTime(), ); const event = new Event("change"); const time_start = document.getElementById("id_time_start"); @@ -354,10 +408,10 @@ var updateDom = function (data) { var manageSave = function () { let player = window.videojs.players.podvideoplayer; - if (player.usingPlugin("videoJsChapters")) { + if (player.usingPlugin("podVideoJsChapters")) { player.main(); } else { - player.videoJsChapters(); + player.podVideoJsChapters(); } }; @@ -368,7 +422,7 @@ var manageDelete = function () { player.main(); } else { player.controlBar.chapters.dispose(); - player.videoJsChapters().dispose(); + player.podVideoJsChapters().dispose(); } }; @@ -376,17 +430,17 @@ var manageImport = function () { let player = window.videojs.players.podvideoplayer; let n = document.querySelector("div.chapters-list"); if (n != null) { - if (player.usingPlugin("videoJsChapters")) { + if (player.usingPlugin("podVideoJsChapters")) { player.main(); } else { - player.videoJsChapters(); + player.podVideoJsChapters(); } } else { if (typeof player.controlBar.chapters != "undefined") { player.controlBar.chapters.dispose(); } - if (player.usingPlugin("videoJsChapters")) { - player.videoJsChapters().dispose(); + if (player.usingPlugin("podVideoJsChapters")) { + player.podVideoJsChapters().dispose(); } } }; diff --git a/pod/chapter/static/js/videojs-chapters.js b/pod/chapter/static/js/videojs-chapters.js index 3173ca7e58..791b9170c8 100644 --- a/pod/chapter/static/js/videojs-chapters.js +++ b/pod/chapter/static/js/videojs-chapters.js @@ -8,19 +8,18 @@ } (function (window, videojs) { - var videoJsChapters, - defaults = { - ui: true, - }; + var defaults = { + ui: true, + }; /* * Chapter menu button */ var MenuButton = videojs.getComponent("MenuButton"); - var ChapterMenuButton = videojs.extend(MenuButton, { - constructor: function (player, options) { + class ChapterMenuButton extends MenuButton { + constructor(player, options) { options.label = gettext("Chapters"); - MenuButton.call(this, player, options); + super(player, options); this.el().setAttribute("aria-label", gettext("Chapters")); videojs.dom.addClass(this.el(), "vjs-chapters-button"); this.controlText(gettext("Chapters")); @@ -28,34 +27,36 @@ var span = document.createElement("span"); videojs.dom.addClass(span, "vjs-chapters-icon"); this.el().appendChild(span); - }, - }); - ChapterMenuButton.prototype.handleClick = function (event) { - MenuButton.prototype.handleClick.call(this, event); - if (document.querySelectorAll(".chapters-list.inactive li").length > 0) { - document - .querySelector(".chapters-list.inactive") - .setAttribute("class", "chapters-list active"); - document.querySelector(".vjs-chapters-button button").style = - "text-shadow: 0 0 1em #fff"; - } else { - document - .querySelector(".chapters-list.active") - .setAttribute("class", "chapters-list inactive"); - - document.querySelector(".vjs-chapters-button button").style = - "text-shadow: '' "; } - }; + handleClick(event) { + MenuButton.prototype.handleClick.call(this, event); + if ( + document.querySelectorAll(".chapters-list.inactive li").length > 0 + ) { + document + .querySelector(".chapters-list.inactive") + .setAttribute("class", "chapters-list active"); + document.querySelector(".vjs-chapters-button button").style = + "text-shadow: 0 0 1em #fff"; + } else { + document + .querySelector(".chapters-list.active") + .setAttribute("class", "chapters-list inactive"); + + document.querySelector(".vjs-chapters-button button").style = + "text-shadow: '' "; + } + } + } MenuButton.registerComponent("ChapterMenuButton", ChapterMenuButton); /** * Initialize the plugin. */ var Plugin = videojs.getPlugin("plugin"); - videoJsChapters = videojs.extend(Plugin, { - constructor: function (player, options) { - Plugin.call(this, player, options); + class podVideoJsChapters extends Plugin { + constructor(player, options) { + super(player, options); var settings = videojs.mergeOptions(defaults, options), chapters = {}, currentChapter = document.createElement("li"); @@ -78,6 +79,8 @@ var newA = document.createElement("a"); newA.setAttribute("id", "chapter" + chapId); newA.setAttribute("start", chapTime); + newA.setAttribute("role", "button"); + newA.setAttribute("tabindex", "0"); var newTitle = document.createTextNode(chapTitle); newA.appendChild(newTitle); @@ -89,7 +92,7 @@ function () { player.currentTime(this.attributes.start.value); }, - false + false, ); } /* What is the purpose of this code ?? @@ -132,7 +135,7 @@ for (let i = 0; i <= keys.length - 1; i++) { var next = chapters.start[i + 1] || player.duration(); currentChapter = document.getElementById( - "chapter" + chapters.id[i] + "chapter" + chapters.id[i], ); if (currentTime >= chapters.start[i] && currentTime < next) { currentChapter.classList.add("current"); @@ -152,7 +155,7 @@ var menuButton = new ChapterMenuButton(player, settings); player.controlBar.chapters = player.controlBar.el_.insertBefore( menuButton.el_, - player.controlBar.getChild("fullscreenToggle").el_ + player.controlBar.getChild("fullscreenToggle").el_, ); player.controlBar.chapters.dispose = function () { this.parentNode.removeChild(this); @@ -177,17 +180,17 @@ this.on(player, "timeupdate", function () { player.getCurrentChapter( player.currentTime(), - player.getGroupedChapters() + player.getGroupedChapters(), ); }); - }, - }); + } + } - videoJsChapters.prototype.dispose = function () { + podVideoJsChapters.prototype.dispose = function () { Plugin.prototype.dispose.call(this); }; // Register the plugin - videojs.registerPlugin("videoJsChapters", videoJsChapters); + videojs.registerPlugin("podVideoJsChapters", podVideoJsChapters); })(window, videojs); })(); diff --git a/pod/chapter/templates/chapter/form_chapter.html b/pod/chapter/templates/chapter/form_chapter.html index 978c25f37e..a4027ecd4d 100644 --- a/pod/chapter/templates/chapter/form_chapter.html +++ b/pod/chapter/templates/chapter/form_chapter.html @@ -1,75 +1,80 @@ {# HTML for chapter form. Don't use this file alone it must be integrated into another template! #} {% load i18n %} {% load static %} -
-
-
-

{% trans 'Create / Edit chapters' %}

-
-
- {% csrf_token %} -
- {% if form_chapter.errors or form_chapter.non_field_errors %} - {% trans 'One or more errors have been found in the form:' %}
- {% for error in form_chapter.non_field_errors %} - - {{error}}
- {% endfor %} - {% endif %} - {% for field_hidden in form_chapter.hidden_fields %} - {{field_hidden}} - {% endfor %} - {% for field in form_chapter.visible_fields %} -
- -
{{field}}
-
- {% endfor %} - {% if form_chapter.instance %} - - {% endif %} - -
- - -
-
-
-   - {% if request.user.is_staff %} -
-

{% trans 'Import chapters' %}

-
-
- {% csrf_token %} -
- {% if form_import.errors or form_import.non_field_errors %} - {% trans 'One or more errors have been found in the form:' %}
- {% for error in form_import.non_field_errors %} - - {{error}}
- {% endfor %} - {% endif %} - {% for field_hidden in form_import.hidden_fields %} - {{field_hidden}} - {% endfor %} - {% for field in form_import.visible_fields %} -
- -
{{field}}
-
- {% for error in field.errors %} -
- {{error|escape}} -
- {% endfor %} - {% endfor %} - - {% trans 'The file must be in VTT format.' %} -
- -
-
-
- {{form_import.media}} - {% endif %} -
-
+
+ {% trans 'Create / Edit chapters' %} +
+ {% csrf_token %} +
+ {% if form_chapter.errors or form_chapter.non_field_errors %} + {% trans 'One or more errors have been found in the form:' %}
+ {% for error in form_chapter.non_field_errors %} + - {{error}}
+ {% endfor %} + {% endif %} + {% for field_hidden in form_chapter.hidden_fields %} + {{field_hidden}} + {% endfor %} + {% for field in form_chapter.visible_fields %} +
+ +
+ {{field}} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
+ {% endfor %} + {% if form_chapter.instance %} + + {% endif %} + +
+ + +
+
+
+
+ +{% if request.user.is_staff and form_import.visible_fields %} +
+ {% trans 'Import chapters' %} +
+ {% csrf_token %} +
+ {% if form_import.errors or form_import.non_field_errors %} + {% trans 'One or more errors have been found in the form:' %}
+ {% for error in form_import.non_field_errors %} + - {{error}}
+ {% endfor %} + {% endif %} + {% for field_hidden in form_import.hidden_fields %} + {{field_hidden}} + {% endfor %} + {% for field in form_import.visible_fields %} +
+ +
+ {{field}} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
+ {% for error in field.errors %} +
+ {{error|escape}} +
+ {% endfor %} + {% endfor %} + +
+ +
+
+
+ {{form_import.media}} +
+{% endif %} diff --git a/pod/chapter/templates/chapter/list_chapter.html b/pod/chapter/templates/chapter/list_chapter.html index 9f319ad3ae..916db35e99 100644 --- a/pod/chapter/templates/chapter/list_chapter.html +++ b/pod/chapter/templates/chapter/list_chapter.html @@ -24,13 +24,13 @@

{% trans 'List of chapters' %} ({ {% csrf_token %} - +
{% csrf_token %} - +

diff --git a/pod/chapter/templates/video_chapter.html b/pod/chapter/templates/video_chapter.html index 8cd60f2d28..eb42dbdad8 100644 --- a/pod/chapter/templates/video_chapter.html +++ b/pod/chapter/templates/video_chapter.html @@ -4,77 +4,85 @@ {% load static %} {% block page_title %}{% trans 'Chapter video' %} "{{video.title}}" {% endblock page_title %} {% block page_extra_head %} - -{% include 'videos/video-header.html' %} - - + {% include 'videos/video-header.html' %} + + {% endblock page_extra_head %} {% block breadcrumbs %} -{{block.super}} - - - -{% endblock %} + {{block.super}} + + + +{% endblock breadcrumbs %} {% block page_content %} - - {% include 'videos/video-element.html' %} - -
-
-
-
- {% include 'chapter/list_chapter.html' %} -
-
- {% if form_chapter %} - {% include 'chapter/form_chapter.html' with form_chapter=form_chapter %} + + {% include 'videos/video-element.html' %} + +
+
+
+
+ {% include 'chapter/list_chapter.html' %} +
+
+ {% if form_chapter %} + {% include 'chapter/form_chapter.html' with form_chapter=form_chapter %} + {% endif %} +
+ + +  {% trans "Back to the video"%} + + + {% if not form_chapter %} +
+ {% csrf_token %} + + +
{% endif %}
- - -  {% trans "Back to the video"%} - - - {% if not form_chapter %} -
- {% csrf_token %} - - -
- {% endif %}
-
{% endblock page_content %} {% block page_aside %} -{% if video.owner == request.user or request.user.is_superuser or perms.chapter.add_chapter or request.user in video.additional_owners.all %} -
-

 {% trans "Manage video"%}

-
- {% include "videos/link_video.html" with hide_favorite_link=True %} + {% if video.owner == request.user or request.user.is_superuser or perms.chapter.add_chapter or request.user in video.additional_owners.all %} +
+

 {% trans "Manage video"%}

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

{% trans "Help"%}

+ +
+

{% trans '“Add a new chapter” allows you to add a chapter to the video, “modify” allows you to edit it and “delete” allows you to remove the chapter.' %}

+

{% trans 'Start playback of the video, pause the video and click on “Get time from the player” to fill in the field untitled “Start time”.' %}

+

{% trans 'The chapters cannot start at the same time.' %}

+

{% trans 'You must save your chapters to view the result.' %}

+
-
-{% endif %} -
-

{% trans "Help"%}

- -
-

{% trans '"Add a new chapter" allows you to add a new chapter, "modify" allows you to modify it and "delete" allows you to remove the chapter.' %}

-

{% trans 'Start playback of the video, pause the video and click on "Get time from the player" to fill in the field untitled "Start time".' %}

-

{% trans 'The chapters cannot start at the same time.' %}

-

{% trans 'You must save your chapters to view the result.' %}

+
+

{% trans "Mandatory fields" %}

+
+

+ * + {% trans "Fields marked with an asterisk are mandatory." %} +

+
-
{% endblock page_aside %} {% block more_script %} -{% include 'videos/video-script.html'%} -{% endblock more_script %} \ No newline at end of file + {% include 'videos/video-script.html'%} +{% endblock more_script %} diff --git a/pod/chapter/views.py b/pod/chapter/views.py index 0f5a417834..a67010ab32 100644 --- a/pod/chapter/views.py +++ b/pod/chapter/views.py @@ -54,6 +54,7 @@ def video_chapter(request, slug): def video_chapter_new(request, video): + """Display a new video chapter form.""" list_chapter = video.chapter_set.all() form_chapter = ChapterForm(initial={"video": video}) form_import = ChapterImportForm(user=request.user, video=video) @@ -81,6 +82,7 @@ def video_chapter_new(request, video): def video_chapter_save(request, video): + """Save a video chapter form request.""" list_chapter = video.chapter_set.all() form_chapter = None @@ -149,6 +151,7 @@ def video_chapter_save(request, video): def video_chapter_modify(request, video): + """Display a video chapter modification form.""" list_chapter = video.chapter_set.all() if request.POST.get("action", "").lower() == "modify": chapter = get_object_or_404(Chapter, id=request.POST.get("id")) diff --git a/pod/completion/forms.py b/pod/completion/forms.py index 7b17d1abcd..9c15e54681 100644 --- a/pod/completion/forms.py +++ b/pod/completion/forms.py @@ -27,6 +27,8 @@ def __init__(self, *args, **kwargs): """Initialize fields.""" super(ContributorForm, self).__init__(*args, **kwargs) self.fields["video"].widget = HiddenInput() + self.fields["name"].widget.attrs["autocomplete"] = "name" + self.fields["email_address"].widget.attrs["autocomplete"] = "email" self.fields = add_placeholder_and_asterisk(self.fields) class Meta(object): diff --git a/pod/completion/models.py b/pod/completion/models.py index b1b3eaaac0..767bd7aea2 100644 --- a/pod/completion/models.py +++ b/pod/completion/models.py @@ -45,7 +45,10 @@ LANG_CHOICES = getattr( settings, "LANG_CHOICES", - ((" ", PREF_LANG_CHOICES), ("----------", ALL_LANG_CHOICES)), + ( + (_("-- Frequently used languages --"), PREF_LANG_CHOICES), + (_("-- All languages --"), ALL_LANG_CHOICES), + ), ) __LANG_CHOICES_DICT__ = { key: value for key, value in LANG_CHOICES[0][1] + LANG_CHOICES[1][1] @@ -246,7 +249,7 @@ def verify_attributs(self): if not self.src: msg.append(_("Please specify a track file.")) elif "vtt" not in self.src.file_type: - msg.append(_('Only ".vtt" format is allowed.')) + msg.append(_("Only “.vtt” format is allowed.")) if len(msg) > 0: return msg else: diff --git a/pod/completion/static/css/completion.css b/pod/completion/static/css/completion.css index 83ab5c47bc..d50aee21a8 100644 --- a/pod/completion/static/css/completion.css +++ b/pod/completion/static/css/completion.css @@ -120,13 +120,6 @@ table#table_list_contributors thead th.contributor_name { .grid-list-track .track_kind.options > form, .grid-list-track .track_kind.options > #modifCapSubFile{ display: none;} } -/** Forms **/ - - -/** Aside **/ -.info-card { - display: none; -} /** Filepicker override **/ div.file-picker-overlay, @@ -142,4 +135,3 @@ textarea#id_description { .card-title { margin: .45rem; } - diff --git a/pod/completion/static/js/caption_maker.js b/pod/completion/static/js/caption_maker.js index e33411ff44..11c0a3704e 100644 --- a/pod/completion/static/js/caption_maker.js +++ b/pod/completion/static/js/caption_maker.js @@ -33,19 +33,19 @@ document.addEventListener("DOMContentLoaded", function () { send_form_data(url, data, "ProcessProxyVttResponse"); } else { document.getElementById( - "captionFilename" + "captionFilename", ).value = `${file_prefix}_captions_${Date.now()}`; } let placeholder = gettext( - "WEBVTT\n\nstart time(00:00.000) --> end time(00:00.000)\ncaption text" + "WEBVTT\n\nstart time(00:00.000) --> end time(00:00.000)\ncaption text", ); let captionContent = document.getElementById("captionContent"); captionContent.setAttribute("placeholder", placeholder); captionContent.addEventListener("mouseup", function (e) { let selectedText = this.value.substring( this.selectionStart, - this.selectionEnd + this.selectionEnd, ); playSelectedCaption(selectedText.trim()); @@ -163,7 +163,7 @@ const send_form_save_captions = function () { error + ")
" + gettext("no data could be stored."), - "alert-danger" + "alert-danger", ); }); }; @@ -182,17 +182,17 @@ document break; case event.originalEvent.target.error.MEDIA_ERR_NETWORK: video_error.textContent = gettext( - "A network error caused the video download to fail part-way." + "A network error caused the video download to fail part-way.", ); break; case event.originalEvent.target.error.MEDIA_ERR_DECODE: video_error.textContent = gettext( - "The video playback was aborted due to a corruption problem or because the video used features your browser did not support." + "The video playback was aborted due to a corruption problem or because the video used features your browser did not support.", ); break; case event.originalEvent.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED: video_error.textContent = gettext( - "The video could not be loaded, either because the server or network failed or because the format is not supported." + "The video could not be loaded, either because the server or network failed or because the format is not supported.", ); break; @@ -226,7 +226,7 @@ document.getElementById("addSubtitle").addEventListener("click", function (e) { AddCaption( captionsEndTime, playTime > captionsEndTime ? playTime : parseInt(captionsEndTime) + 2, - "" + "", ); }); @@ -281,11 +281,11 @@ function DisplayExistingCaption(seconds) { ]); document.getElementById("textCaptionEntry").value = theCaption.caption; - document.getElementById("previewTrack").value = theCaption.caption; + //document.getElementById("previewTrack").value = theCaption.caption; } else { document.getElementById("captionTitle").innerHTML = " "; document.getElementById("textCaptionEntry").value = ""; - document.getElementById("previewTrack").value = ""; + //document.getElementById("previewTrack").value = ""; } } @@ -358,14 +358,14 @@ function videoPauseEventHandler() { message = gettext("Edit caption for segment from %s to %s:"); document.getElementById("captionTitle").textContent = interpolate( message, - [FormatTime(theCaption.start), FormatTime(theCaption.end)] + [FormatTime(theCaption.start), FormatTime(theCaption.end)], ); textCaption.value = theCaption.caption; captionBeingDisplayed = ci; } else { document.getElementById("captionTitle").textContent = gettext( - "No caption at this time code." + "No caption at this time code.", ); textCaption.value = ""; captionBeingDisplayed = -1; @@ -423,7 +423,7 @@ function EnableDemoAfterLoadVideo() { }); document .querySelectorAll( - ".grayNoVideo a, .grayNoVideo button, .grayNoVideo input, .grayNoVideo textarea" + ".grayNoVideo a, .grayNoVideo button, .grayNoVideo input, .grayNoVideo textarea", ) .forEach(function (e) { e.disabled = false; @@ -547,77 +547,77 @@ function CreateCaptionBlock(newCaption, spawnFunction) { // parent div: new DOMParser().parseFromString( `
`, - "text/html" + "text/html", ).body.firstChild, // circle buttons buttonsDiv: new DOMParser().parseFromString( "
", - "text/html" + "text/html", ).body.firstChild, insertBtn: new DOMParser().parseFromString( ``, - "text/html" + "text/html", ).body.firstChild, deleteBtn: new DOMParser().parseFromString( ``, - "text/html" + "text/html", ).body.firstChild, // textarea captionDiv: new DOMParser().parseFromString( "
", - "text/html" + "text/html", ).body.firstChild, //captionTextInput: $(``), captionTextLabel: new DOMParser().parseFromString( ``, - "text/html" + "text/html", ).body.firstChild, captionTextInput: new DOMParser().parseFromString( ``, - "text/html" + "text/html", ).body.firstChild, // time editable timeBlockEditable: new DOMParser().parseFromString( `"`, - "text/html" + "text/html", ).body.firstChild, startTimeLabel: new DOMParser().parseFromString( ``, - "text/html" + "text/html", ).body.firstChild, startTimeInput: new DOMParser().parseFromString( ``, - "text/html" + "text/html", ).body.firstChild, endTimeLabel: new DOMParser().parseFromString( ``, - "text/html" + "text/html", ).body.firstChild, endTimeInput: new DOMParser().parseFromString( ``, - "text/html" + "text/html", ).body.firstChild, // time links timeBlock: new DOMParser().parseFromString( `
${gettext( - "Time stamps" + "Time stamps", )}
`, - "text/html" + "text/html", ).body.firstChild, startTimeBtn: new DOMParser().parseFromString( `${start}`, - "text/html" + "text/html", ).body.firstChild, endTimeBtn: new DOMParser().parseFromString( `${end}`, - "text/html" + "text/html", ).body.firstChild, // flags isEditEnabled: false, @@ -669,7 +669,7 @@ function CreateCaptionBlock(newCaption, spawnFunction) { if (cap.start > newCaption.start) { // move caption object in captionsArray let index = Array.from(this.div.parentNode.children).indexOf( - this.div + this.div, ); let fromI = index; let toI = fromI < i ? i - 1 : i; @@ -680,7 +680,7 @@ function CreateCaptionBlock(newCaption, spawnFunction) { this.div.remove(); cap.blockObject.div.parentNode.insertBefore( this.div, - cap.blockObject.div + cap.blockObject.div, ); return; } @@ -710,7 +710,7 @@ function CreateCaptionBlock(newCaption, spawnFunction) { captionsArray.splice(index + 1, 0, captionObj); CreateCaptionBlock(captionObj, (newDiv) => - this.div.parentNode.insertBefore(newDiv, this.div.nextSibling) + this.div.parentNode.insertBefore(newDiv, this.div.nextSibling), ); }, @@ -733,10 +733,10 @@ function CreateCaptionBlock(newCaption, spawnFunction) { this.insertBtn.addEventListener("click", () => this.spawnNew()); this.deleteBtn.addEventListener("click", () => this.delete()); this.startTimeBtn.addEventListener("click", () => - seekVideoTo(newCaption.start) + seekVideoTo(newCaption.start), ); this.endTimeBtn.addEventListener("click", () => - seekVideoTo(newCaption.end) + seekVideoTo(newCaption.end), ); this.captionTextInput.addEventListener("focus", () => this.enableEdit()); @@ -750,7 +750,7 @@ function CreateCaptionBlock(newCaption, spawnFunction) { this.startTimeLabel, this.startTimeInput, this.endTimeLabel, - this.endTimeInput + this.endTimeInput, ); this.buttonsDiv.append(this.insertBtn, this.deleteBtn); @@ -760,7 +760,7 @@ function CreateCaptionBlock(newCaption, spawnFunction) { this.buttonsDiv, this.captionDiv, this.timeBlock, - this.timeBlockEditable + this.timeBlockEditable, ); this.startTimeInput.addEventListener("keydown", (e) => { @@ -805,7 +805,7 @@ function CreateCaptionBlock(newCaption, spawnFunction) { }, function () { clearVideoRegion(); - } + }, ); document.getElementById("noCaptionsText")?.remove(); @@ -1072,7 +1072,7 @@ function LoadCaptionFile(fileObject) { reader.readAsText(fileObject); } catch (exc) { alert( - gettext("Exception thrown reading caption file. Code = ") + exc.code + gettext("Exception thrown reading caption file. Code = ") + exc.code, ); } } else { @@ -1098,7 +1098,7 @@ function ProcessProxyVttResponse(obj) { // strip file extension and set as title document.getElementById("captionFilename").value = obj.file_name.replace( /\.[^/.]+$/, - "" + "", ); if (obj.response.match(/^WEBVTT/)) { diff --git a/pod/completion/static/js/completion.js b/pod/completion/static/js/completion.js index 6ac58b4cdb..18a50c7577 100644 --- a/pod/completion/static/js/completion.js +++ b/pod/completion/static/js/completion.js @@ -87,7 +87,7 @@ var ajaxfail = function (data, form) { data + ") " + gettext("The form could not be recovered."), - "alert-danger" + "alert-danger", ); }; @@ -99,7 +99,8 @@ document.addEventListener("submit", (e) => { 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_change") && + !e.target.matches(".form_delete") ) return; @@ -130,10 +131,19 @@ var sendandgetform = async function (elt, action, name, form, list) { if (action == "new" || action == "form_save_new") { document.getElementById(form).innerHTML = '
'; - document.querySelectorAll(".info-card").forEach(function (element) { - element.style.display = "none"; - }); - document.getElementById(name + "-info").style.display = "block"; + + document + .querySelectorAll( + `#card-completion-tips div:not(#${name}-info) .collapse`, + ) + .forEach((collapsable) => { + bootstrap.Collapse.getOrCreateInstance(collapsable, { + toggle: false, + }).hide(); + }); + /* Display associated help in side menu */ + var compInfo = document.querySelector(`#${name}-info>.collapse`); + bootstrap.Collapse.getOrCreateInstance(compInfo).show(); let url = window.location.origin + href; let token = elt.csrfmiddlewaretoken.value; @@ -156,8 +166,8 @@ var sendandgetform = async function (elt, action, name, form, list) { showalert( gettext( "You are no longer authenticated. Please log in again.", - "alert-danger" - ) + "alert-danger", + ), ); } else { show_form(data, form); @@ -211,8 +221,8 @@ var sendandgetform = async function (elt, action, name, form, list) { showalert( gettext( "You are no longer authenticated. Please log in again.", - "alert-danger" - ) + "alert-danger", + ), ); } else { show_form(data, form); @@ -233,26 +243,26 @@ var sendandgetform = async function (elt, action, name, form, list) { var deleteConfirm = ""; if (name == "track") { deleteConfirm = confirm( - gettext("Are you sure you want to delete this file?") + gettext("Are you sure you want to delete this file?"), ); } else if (name == "contributor") { deleteConfirm = confirm( - gettext("Are you sure you want to delete this contributor?") + gettext("Are you sure you want to delete this contributor?"), ); } else if (name == "document") { deleteConfirm = confirm( - gettext("Are you sure you want to delete this document?") + gettext("Are you sure you want to delete this document?"), ); } else if (name == "overlay") { deleteConfirm = confirm( - gettext("Are you sure you want to delete this overlay?") + gettext("Are you sure you want to delete this overlay?"), ); } if (deleteConfirm) { var id = elt.querySelector("input[name=id]").value; var url = window.location.origin + href; var token = document.querySelector( - "input[name=csrfmiddlewaretoken]" + "input[name=csrfmiddlewaretoken]", ).value; let form_data = new FormData(); form_data.append("action", action); @@ -275,8 +285,8 @@ var sendandgetform = async function (elt, action, name, form, list) { showalert( gettext( "You are no longer authenticated. Please log in again.", - "alert-danger" - ) + "alert-danger", + ), ); } }) @@ -295,7 +305,7 @@ var sendandgetform = async function (elt, action, name, form, list) { let data_form = new FormData(form_el); let url = window.location.origin + href; let token = document.querySelector( - "input[name=csrfmiddlewaretoken]" + "input[name=csrfmiddlewaretoken]", ).value; await fetch(url, { @@ -320,8 +330,8 @@ var sendandgetform = async function (elt, action, name, form, list) { showalert( gettext( "You are no longer authenticated. Please log in again.", - "alert-danger" - ) + "alert-danger", + ), ); } }) @@ -374,8 +384,10 @@ function refresh_list(data, form, list) { document.querySelectorAll("a.title").forEach(function (element) { element.style.display = "initial"; }); - document.getElementById("enrich_player").innerHTML = data.player; - documentL.getElementById(list).innerHTML = data.list_data; + if (data.player) { + document.getElementById("enrich_player").innerHTML = data.player; + } + document.getElementById(list).innerHTML = data.list_data; } // Check fields @@ -391,7 +403,7 @@ function verify_fields(form) { input.parentNode.append( "  " + gettext("Please enter a name from 2 to 100 caracteres.") + - "" + "", ); let form_group = input.closest("div.form-group"); @@ -405,7 +417,7 @@ function verify_fields(form) { "afterend", "  " + gettext("You cannot enter a weblink with more than 200 caracteres.") + - "" + "", ); let form_group = id_weblink.closest("div.form-group"); form_group.classList.add("has-error"); @@ -418,7 +430,7 @@ function verify_fields(form) { "afterend", "  " + gettext("Please enter a role.") + - "" + "", ); let form_group = id_role.closest("div.form-group"); form_group.classList.add("has-error"); @@ -437,7 +449,7 @@ function verify_fields(form) { tr.querySelector("td[class=contributor_role]").innerHTML == new_role ) { var text = gettext( - "There is already a contributor with this same name and role in the list." + "There is already a contributor with this same name and role in the list.", ); showalert(text, "alert-danger"); error = true; @@ -455,7 +467,7 @@ function verify_fields(form) { "afterend", "" + gettext("Please enter a correct kind.") + - "" + "", ); let form_group = id_kind.closest("div.form-group"); form_group.classList.add("has-error"); @@ -472,7 +484,7 @@ function verify_fields(form) { "afterend", "" + gettext("Please select a language.") + - "" + "", ); let form_group = id_lang.closest("div.form-group"); form_group.classList.add("has-error"); @@ -489,7 +501,7 @@ function verify_fields(form) { "afterend", "" + gettext("Please specify a track file.") + - "" + "", ); let form_group = id_src.closest("div.form-group"); form_group.classList.add("has-error"); @@ -499,7 +511,7 @@ function verify_fields(form) { "afterend", "" + gettext("Only .vtt format is allowed.") + - "" + "", ); let form_group = id_src.closest("div.form-group"); form_group.classList.add("has-error"); @@ -533,9 +545,9 @@ function verify_fields(form) { "afterend", "" + gettext( - "There is already a track with the same kind and language in the list." + "There is already a track with the same kind and language in the list.", ) + - "" + "", ); let form_group = id_src.closest("div.form-group"); form_group.classList.add("has-error"); @@ -548,13 +560,13 @@ function verify_fields(form) { .getAttribute("src") == "/static/icons/nofile_48x48.png" ) { let id_document_thumbnail = document.getElementById( - "id_document_thubmanil_img" + "id_document_thubmanil_img", ); id_document_thumbnail.insertAdjacentHTML( "afterend", "" + gettext("Please select a document") + - "" + "", ); let form_group = id_document_thumbnail.closest("div.form-group"); form_group.classList.add("has-error"); @@ -569,7 +581,7 @@ function verify_fields(form) { "afterend", "  " + gettext("Iframe and Script tags are not allowed.") + - "" + "", ); let form_group = id_content.closest("div.form-group"); form_group.forEach(function (element) { diff --git a/pod/completion/templates/contributor/list_contributor.html b/pod/completion/templates/contributor/list_contributor.html index d05172f978..922907664b 100644 --- a/pod/completion/templates/contributor/list_contributor.html +++ b/pod/completion/templates/contributor/list_contributor.html @@ -26,13 +26,13 @@

{% trans 'List of contributors' %} ( {% csrf_token %} - +
{% csrf_token %} - +
diff --git a/pod/completion/templates/document/list_document.html b/pod/completion/templates/document/list_document.html index 8f358c55d4..03a92dcf4b 100644 --- a/pod/completion/templates/document/list_document.html +++ b/pod/completion/templates/document/list_document.html @@ -20,13 +20,13 @@

{% trans 'List of additional resources' % {% csrf_token %} - +
{% csrf_token %} - +
diff --git a/pod/completion/templates/overlay/list_overlay.html b/pod/completion/templates/overlay/list_overlay.html index 7224481e07..7ab65e0a87 100644 --- a/pod/completion/templates/overlay/list_overlay.html +++ b/pod/completion/templates/overlay/list_overlay.html @@ -26,13 +26,13 @@

{% trans 'List of overlays' %} ({{li {% csrf_token %} - +
{% csrf_token %} - +
diff --git a/pod/completion/templates/track/list_track.html b/pod/completion/templates/track/list_track.html index 0042119bdc..42b5c6fc63 100644 --- a/pod/completion/templates/track/list_track.html +++ b/pod/completion/templates/track/list_track.html @@ -18,14 +18,14 @@

{% trans 'List of subtitle or caption fil {% csrf_token %} - + - {% trans 'Modify' %} + {% trans 'Modify' %}
{% csrf_token %} - +
diff --git a/pod/completion/templates/video_caption_maker.html b/pod/completion/templates/video_caption_maker.html index 1b305a6db0..c86f707410 100644 --- a/pod/completion/templates/video_caption_maker.html +++ b/pod/completion/templates/video_caption_maker.html @@ -69,7 +69,7 @@

{% endfor %} diff --git a/pod/completion/templates/video_completion.html b/pod/completion/templates/video_completion.html index c723103257..fde4597ba2 100644 --- a/pod/completion/templates/video_completion.html +++ b/pod/completion/templates/video_completion.html @@ -58,17 +58,20 @@   @@ -131,53 +134,57 @@

{% endif %} -
+

{% trans "Help"%}

- -
-

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

-

{% trans 'A contributor must at least have a name and a role. You can also join the email of this contributor as well as a link (professional website for example).' %}

-
-
-
- -
-

{% trans 'Subtitle(s) and/or captions(s) related to this video.' %}

- -

{% trans 'You can add several subtitle or caption files to a signle video (for example, in order to subtitle or caption this video in several languages' %}

-

{% trans 'Subtitles and/or caption(s) files must be in ".vtt" format.' %}

-

{% trans 'You can use' %} {% trans 'Video Caption Maker' %} {% trans 'to create your subtitles and/or caption(s) files.' %}

-

{% trans 'You will need the URL of this video to make subtitles and/or captions. This URL is a direct access to this video. Please do not communicate it outside of this site to avoid any misuse.' %}

-
    - {% for vid in video.get_video_mp4 %} -
  • {{vid.name}}:
  • - {% endfor %} - {% if video.is_video == False and video.get_video_mp3 %} -
  • {{video.get_video_mp3.name}}:
  • - {% endif %} -
-
-
-
- -
-

{% trans 'Document(s) related to this video. These documents will be downloadable by users.' %}

-

{% trans 'Be careful, not to be confused with enrichment. These documents are attached to the video, not integrated.' %}

-
-
-
- -
-

{% trans 'Overlay allows you to display text (with ou without html tag) over the video at specific times and positions.' %}

-

{% trans 'You can add a solid background or a transparent background to the text you want to display with the option "Show background"' %}

+
+
+ +
+

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

+

{% trans 'A contributor must at least have a name and a role. You can also join the email of this contributor as well as a link (professional website for example).' %}

+
+
+
+ +
+

{% trans 'Subtitle(s) and/or captions(s) related to this video.' %}

+ +

{% trans 'You can add several subtitle or caption files to a single video (for example, in order to subtitle or caption this video in several languages' %}

+

{% trans 'Subtitle and/or caption file(s) must be in “.vtt” format.' %}

+

{% trans 'You can use' %} {% trans 'Video Caption Maker' %} {% trans 'to create your subtitle(s) and/or caption(s) file(s).' %}

+

{% trans 'You will need the URL of this video to make subtitles and/or captions. This URL is a direct access to this video. Please do not communicate it outside of this site to avoid any misuse.' %}

+
    + {% for vid in video.get_video_mp4 %} +
  • {{vid.name}}:
  • + {% endfor %} + {% if video.is_video == False and video.get_video_mp3 %} +
  • {{video.get_video_mp3.name}}:
  • + {% endif %} +
+
+
+
+ +
+

{% trans 'Document(s) related to this video. These documents will be downloadable by users.' %}

+

{% trans 'Be careful, not to be confused with enrichment. These documents are attached to the video, not integrated.' %}

+
+
+
+ +
+

{% trans 'Overlay allows you to display text (with ou without html tag) over the video at specific times and positions.' %}

+

{% trans 'You can add a solid background or a transparent background to the text you want to display with the option "Show background"' %}

+
+
{% endblock page_aside %} diff --git a/pod/completion/views.py b/pod/completion/views.py index 602ea72ac6..fcccc5b48c 100644 --- a/pod/completion/views.py +++ b/pod/completion/views.py @@ -36,7 +36,10 @@ LANG_CHOICES = getattr( settings, "LANG_CHOICES", - ((" ", PREF_LANG_CHOICES), ("----------", ALL_LANG_CHOICES)), + ( + (_("-- Frequently used languages --"), PREF_LANG_CHOICES), + (_("-- All languages --"), ALL_LANG_CHOICES), + ), ) __LANG_CHOICES_DICT__ = { key: value for key, value in LANG_CHOICES[0][1] + LANG_CHOICES[1][1] @@ -585,12 +588,12 @@ def video_completion_get_form_track(request): def video_completion_track_save(request, video): """View to save a track associated to a video.""" - form_track = video_completion_get_form_track(request) + list_track = video.track_set.all() if form_track.is_valid(): form_track.save() - list_track = video.track_set.all() + if request.is_ajax(): some_data_to_dump = { "list_data": render_to_string( @@ -633,7 +636,6 @@ def video_completion_track_save(request, video): def video_completion_track_modify(request, video): """View to modify a track associated to a video.""" - track = get_object_or_404(Track, id=request.POST["id"]) form_track = TrackForm(instance=track) if request.is_ajax(): @@ -653,7 +655,6 @@ def video_completion_track_modify(request, video): def video_completion_track_delete(request, video): """View to delete a track associated to a video.""" - track = get_object_or_404(Track, id=request.POST["id"]) track.delete() list_track = video.track_set.all() @@ -769,6 +770,7 @@ def video_completion_overlay_new(request, video): def video_completion_overlay_save(request, video): + """Save a completion overlay.""" if LINK_SUPERPOSITION: request.POST._mutable = True request.POST["content"] = transform_url_to_link(request.POST["content"]) @@ -823,6 +825,7 @@ def video_completion_overlay_save(request, video): def video_completion_overlay_modify(request, video): + """Modify a completion overlay.""" overlay = get_object_or_404(Overlay, id=request.POST["id"]) if LINK_SUPERPOSITION: @@ -844,6 +847,7 @@ def video_completion_overlay_modify(request, video): def video_completion_overlay_delete(request, video): + """Delete a completion overlay.""" overlay = get_object_or_404(Overlay, id=request.POST["id"]) overlay.delete() list_overlay = video.overlay_set.all() diff --git a/pod/cut/static/css/video_cut.css b/pod/cut/static/css/video_cut.css index 48ea3f618f..d487da90ee 100644 --- a/pod/cut/static/css/video_cut.css +++ b/pod/cut/static/css/video_cut.css @@ -1,171 +1,178 @@ #wrapper_cut { - position: relative; - width: 100%; - padding: 50px 10px 20px 10px; - border-radius: 10px; + position: relative; + width: 100%; + padding: 50px 10px 20px 10px; + border-radius: 10px; } #container_cut { - position: relative; - width: 100%; - height: 100px; + position: relative; + width: 100%; + height: 100px; } input[type="range"] { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - width: 100%; - outline: none; - position: absolute; - margin: auto; - top: 0; - bottom: 0; - background-color: transparent; - pointer-events: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + outline: none; + position: absolute; + margin: auto; + top: 0; + bottom: 0; + background-color: transparent; + pointer-events: none; } .slider-track { - width: 100%; - height: 10px; - position: absolute; - margin: auto; - top: 0; - bottom: 0; - border-radius: 5px; - background-color: #1f8389; + width: 100%; + height: 10px; + position: absolute; + margin: auto; + top: 0; + bottom: 0; + border-radius: 5px; + background-color: var(--pod-primary); } +/** + * Range track styles + **/ + input[type="range"]::-webkit-slider-runnable-track { - -webkit-appearance: none; - height: 5px; + -webkit-appearance: none; + height: 5px; } input[type="range"]::-moz-range-track { - -moz-appearance: none; - height: 5px; + -moz-appearance: none; + height: 5px; } input[type="range"]::-ms-track { - appearance: none; - height: 5px; + appearance: none; + height: 5px; } +/** + * Range thumb styles + **/ + input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - height: 1.7em; - width: 1.7em; - background-color: white; - cursor: pointer; - margin-top: -9px; - pointer-events: auto; - border-radius: 50%; - color: #ffffff; - border: 1px solid gray; + -webkit-appearance: none; + margin-top: -9px; + border: 1px solid gray; + + height: 1.7em; + width: 1.7em; + cursor: pointer; + pointer-events: auto; + border-radius: 50%; + background-color: #FFF; } input[type="range"]::-moz-range-thumb { - appearance: none; - height: 1.7em; - width: 1.7em; - cursor: pointer; - border-radius: 50%; - background-color: white; - pointer-events: auto; - color: #ffffff; + appearance: none; + height: 1.7em; + width: 1.7em; + cursor: pointer; + pointer-events: auto; + border-radius: 50%; + background-color: #FFF; } input[type="range"]::-ms-thumb { - appearance: none; - height: 1.7em; - width: 1.7em; - cursor: pointer; - border-radius: 50%; - background-color: white; - pointer-events: auto; - color: #ffffff; + appearance: none; + height: 1.7em; + width: 1.7em; + cursor: pointer; + pointer-events: auto; + border-radius: 50%; + background-color: #FFF; +} + +#container_cut{ + /*--bs-btn-focus-shadow-rgb: var(--pod-primary-rgb);*/ + --bs-btn-focus-shadow-rgb: 200, 200, 253; + --bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);; +} + +input[type="range"]:active::-webkit-slider-thumb, +input[type="range"]:focus-visible::-webkit-slider-thumb { + border: 3px solid var(--pod-primary); + background-color: var(--bs-info-border-subtle); + box-shadow: var(--bs-btn-focus-box-shadow); } -input[type="range"]:active::-webkit-slider-thumb { - background-color: #ffffff; - border: 3px solid #1f8389; +input[type="range"]:active::-moz-range-thumb, +input[type="range"]:focus-visible::-moz-range-thumb { + border: 3px solid var(--pod-primary); + background-color: rgb(var(--bs-btn-focus-shadow-rgb)); + box-shadow: var(--bs-btn-focus-box-shadow); } #values_cut { - background-color: #1f8389; - width: 70%; - position: relative; - margin: auto; - padding: 10px 0; - border-radius: 5px; - text-align: center; - font-weight: 500; - font-size: 25px; - color: #ffffff; + width: 70%; + min-width: 22em; + position: relative; + margin: auto; + padding: 10px 0; + border-radius: 5px; + text-align: center; + font-weight: 500; + font-size: 25px; } #values_cut:before { - content: ""; - position: absolute; - height: 0; - width: 0; - border-top: 15px solid #1f8389; - border-left: 15px solid transparent; - border-right: 15px solid transparent; - margin: auto; - bottom: -14px; - left: 0; - right: 0; + content: ""; + position: absolute; + height: 0; + width: 0; + border-top: 15px solid var(--pod-primary); + border-left: 15px solid transparent; + border-right: 15px solid transparent; + margin: auto; + bottom: -14px; + left: 0; + right: 0; } #total_time { - display: inline-block; - box-sizing: unset; + display: inline-block; + box-sizing: unset; } input[type="time"], #wrapper_cut, #values_cut { - caret-color: transparent; -} - -#button_start, #button_end { - font-size: 34px; - transition-duration: 0.4s; - cursor: pointer; - margin-top: -6px; -} - -#button_start:hover, #button_end:hover { - color: black; - transition-duration: 0.4s; -} - -#button_start { - margin-right: 15px; + caret-color: transparent; } +#button_start, #button_end { - margin-left: 15px; + display: flex; + font-size: 34px; + line-height: 34px; } #cut_video { - display: block; - width: 60%; - margin: auto; + display: block; + width: 60%; + margin: auto; } .time_input { - display: flex; - flex-direction: column-reverse; + display: flex; + flex-direction: column-reverse; } .time_input label { - font-size: 20px; + font-size: 20px; } #time_for_cut { - display: flex; - flex-direction: row; - justify-content: center; + display: flex; + flex-direction: row; + justify-content: center; } diff --git a/pod/cut/static/js/video_cut.js b/pod/cut/static/js/video_cut.js index 7051cc4d68..d8fb8a7e42 100644 --- a/pod/cut/static/js/video_cut.js +++ b/pod/cut/static/js/video_cut.js @@ -142,30 +142,68 @@ noPressEnter(sliderTwo); let button_start = document.getElementById("button_start"); let button_end = document.getElementById("button_end"); -button_start.addEventListener("click", (event) => { +button_start.addEventListener("click", get_video_player_start); +button_start.addEventListener("keydown", function (event) { + if (event.key === " ") { + get_video_player_start(event); + } +}); + +button_end.addEventListener("click", get_video_player_end); +button_end.addEventListener("keydown", function (event) { + if (event.key === " ") { + get_video_player_end(event); + } +}); + +/** + * Retrieves the start time of the video player and updates UI elements accordingly. + */ +function get_video_player_start(event) { + event.preventDefault(); time = Math.trunc(player.currentTime()) + start_time; displayValOne.value = intToTime(time); sliderOne.value = time; fillColor(); -}); +} -button_end.addEventListener("click", (event) => { +/** + * Retrieves the end time of the video player and updates UI elements accordingly. + */ +function get_video_player_end(event) { + event.preventDefault(); time = Math.trunc(player.currentTime()) + start_time; displayValTwo.value = intToTime(time); sliderTwo.value = Math.trunc(time); fillColor(); -}); +} // Button reset -button_reset.addEventListener("click", (event) => { +button_reset.addEventListener("click", resetVideoCut); +button_reset.addEventListener("keydown", function (event) { + if (event.key === " ") { + resetVideoCut(event); + } +}); + +/** + * Resets the video cut to its initial state, updating UI elements and video player. + */ +function resetVideoCut(event) { + event.preventDefault(); displayValOne.value = intToTime(initialStart); sliderOne.value = initialStart; displayValTwo.value = intToTime(initialEnd); sliderTwo.value = initialEnd; fillColor(); player.currentTime(0); -}); +} +/** + * Changes the current time of the video player based on the provided value. + * + * @param {number} value - The new time value to set for the video player. + */ function changeCurrentTimePlayer(value) { // Move the player along with the cursor if (value - initialStart >= 0) { diff --git a/pod/cut/templates/video_cut.html b/pod/cut/templates/video_cut.html index 8750f936e6..f4e40ce98f 100644 --- a/pod/cut/templates/video_cut.html +++ b/pod/cut/templates/video_cut.html @@ -40,11 +40,13 @@
-
+
{% csrf_token %} - + {{ form_cut.video }}
@@ -57,23 +59,30 @@ {{ form_cut.end }}
- +
-  {% trans 'Reset' %} + +  {% trans 'Reset' %} +

@@ -112,10 +121,10 @@

{% 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.' %}

diff --git a/pod/enrichment/models.py b/pod/enrichment/models.py index bb2de1c660..3b906d085d 100755 --- a/pod/enrichment/models.py +++ b/pod/enrichment/models.py @@ -348,7 +348,7 @@ def clean(self): def verify_attributs(self): msg = list() if "vtt" not in self.src.file_type: - msg.append(_('Only ".vtt" format is allowed.')) + msg.append(_("Only “.vtt” format is allowed.")) return msg class Meta: diff --git a/pod/enrichment/static/js/enrichment.js b/pod/enrichment/static/js/enrichment.js index 5d16f7116b..a8bf9d2308 100644 --- a/pod/enrichment/static/js/enrichment.js +++ b/pod/enrichment/static/js/enrichment.js @@ -36,7 +36,7 @@ function show_form(data) { "beforebegin", " " + "
", ); var inputEnd = document.getElementById("id_end"); if (!inputEnd) return; @@ -44,7 +44,7 @@ function show_form(data) { "beforebegin", " " + "
", ); enrich_type(); } @@ -57,7 +57,7 @@ var showalert = function (message, alerttype) { message + '
' + '">

', ); setTimeout(function () { document.getElementById("formalertdiv").remove(); @@ -71,7 +71,7 @@ var ajaxfail = function (data) { data + ") " + gettext("The form could not be recovered."), - "alert-danger" + "alert-danger", ); document.querySelectorAll("form.get_form").forEach((form) => { form.style.display = "block"; @@ -125,7 +125,7 @@ var sendandgetform = async function (elt, action) { ) { showalert( gettext("You are no longer authenticated. Please log in again."), - "alert-danger" + "alert-danger", ); return; } @@ -139,7 +139,7 @@ var sendandgetform = async function (elt, action) { if (data.indexOf("list_enrichment") == -1) { showalert( gettext("You are no longer authenticated. Please log in again."), - "alert-danger" + "alert-danger", ); return; } @@ -187,7 +187,7 @@ var sendform = async function (elt, action) { ) { showalert( gettext("You are no longer authenticated. Please log in again."), - "alert-danger" + "alert-danger", ); } else { data = JSON.parse(data); @@ -202,7 +202,7 @@ var sendform = async function (elt, action) { } else { showalert( gettext("One or more errors have been found in the form."), - "alert-danger" + "alert-danger", ); } } @@ -228,7 +228,7 @@ var sendform = async function (elt, action) { if (data.indexOf("list_enrichment") == -1) { showalert( gettext("You are no longer authenticated. Please log in again."), - "alert-danger" + "alert-danger", ); } else { location.reload(); @@ -293,14 +293,14 @@ function get_form(data) { "beforebegin", " " + "
", ); var inputEnd = document.getElementById("id_end"); inputEnd.insertAdjacentHTML( "beforebegin", " " + "
", ); enrich_type(); manageResize(); @@ -334,7 +334,7 @@ const setTimecode = (e) => { if (e.target.id !== "id_start" && e.target.id !== "id_end") return; const parentNode = e.target.parentNode; const timecodeSpan = parentNode.querySelector( - "div.getfromvideo span.timecode" + "div.getfromvideo span.timecode", ); timecodeSpan.innerHTML = " " + parseInt(e.target.value).toHHMMSS(); }; @@ -370,7 +370,7 @@ function verify_fields() { "beforebegin", "  " + gettext("Please enter a title from 2 to 100 characters.") + - "" + "", ); inputTitle.closest("div.form-group").classList.add("has-error"); @@ -387,7 +387,7 @@ function verify_fields() { "   " + gettext("Please enter a correct start from 0 to ") + (video_duration - 1) + - "" + "", ); inputStart.closest("div.form-group").classList.add("has-error"); @@ -405,7 +405,7 @@ function verify_fields() { "   " + gettext("Please enter a correct end from 1 to ") + video_duration + - "" + "", ); inputEnd.closest("div.form-group").classList.add("has-error"); @@ -421,7 +421,7 @@ function verify_fields() { "beforebegin", "   " + gettext("Please enter a correct image.") + - "" + "", ); img.closest("div.form-group").classList.add("has-error"); error = true; @@ -436,7 +436,7 @@ function verify_fields() { "beforebegin", "   " + gettext("Please enter a correct richtext.") + - "" + "", ); richtext.closest("div.form-group").classList.add("has-error"); @@ -450,7 +450,7 @@ function verify_fields() { "beforebegin", "   " + gettext("Please enter a correct weblink.") + - "" + "", ); weblink.closest("div.form-group").classList.add("has-error"); @@ -461,7 +461,7 @@ function verify_fields() { "beforebegin", "   " + gettext("Weblink must be less than 200 characters.") + - "" + "", ); weblink.closest("div.form-group").classList.add("has-error"); @@ -477,7 +477,7 @@ function verify_fields() { "beforebegin", "   " + gettext("Please select a document.") + - "" + "", ); documentD.closest("div.form-group").classList.add("has-error"); @@ -491,7 +491,7 @@ function verify_fields() { "beforebegin", "   " + gettext("Please enter a correct embed.") + - "" + "", ); embed.closest("div.form-group").classList.add("has-error"); @@ -502,7 +502,7 @@ function verify_fields() { "beforebegin", "   " + gettext("Embed field must be less than 200 characters.") + - "" + "", ); embed.closes("div.form-group").classList.add("has-error"); @@ -517,7 +517,7 @@ function verify_fields() { "beforebegin", "   " + gettext("Please enter a type in index field.") + - "" + "", ); inputType.closest("div.form-group").classList.add("has-error"); diff --git a/pod/enrichment/static/js/videojs-slides.js b/pod/enrichment/static/js/videojs-slides.js index 87b4f3c96d..69a95454d4 100644 --- a/pod/enrichment/static/js/videojs-slides.js +++ b/pod/enrichment/static/js/videojs-slides.js @@ -288,7 +288,7 @@ var VideoSlides = function (items) { */ this.slideBar = function () { const progressbar = document.getElementsByClassName( - "vjs-progress-holder" + "vjs-progress-holder", )[0]; // Create the slidebar var slidebar = document.createElement("div"); @@ -316,7 +316,7 @@ var VideoSlides = function (items) { "%; width: " + slidebar_width + "%; background-color: " + - slide_color[type] + slide_color[type], ); newslide.id = "slidebar_" + i; slidebar_holder.appendChild(newslide); @@ -339,7 +339,7 @@ var VideoSlides = function (items) { } videoplayer.className = vclass; document.getElementsByClassName( - "vjs-slide-manager" + "vjs-slide-manager", )[0].firstChild.firstChild.innerHTML = slide_mode_list[mode]; }); }; @@ -354,17 +354,17 @@ var VideoSlides = function (items) { if (document.getElementsByClassName("vjs-fullscreen-control")) // Create the slide view mode var vjs_menu_item = videojs.getComponent("MenuItem"); - var SlideMode = videojs.extend(vjs_menu_item, { - constructor: function (player, options) { + class SlideMode extends vjs_menu_item { + constructor(player, options) { options = options || {}; options.label = options.label; - vjs_menu_item.call(this, player, options); + super(player, options); this.on("click", this.onClick); this.addClass("vjs-slide-mode"); this.controlText(gettext("Turn to ") + options.mode); this.setAttribute("data-mode", options.mode); - }, - onClick: function () { + } + onClick() { this.setAttribute("aria-checked", true); this.addClass("vjs-selected"); current_slide_mode = this.el().getAttribute("data-mode"); @@ -380,30 +380,30 @@ var VideoSlides = function (items) { e.classList.remove("vjs-selected"); } } - }, - }); + } + } // Create the slide manager menu title - var SlideTitle = videojs.extend(vjs_menu_item, { - constructor: function (player, options) { + class SlideTitle extends vjs_menu_item { + constructor(player, options) { options = options || {}; - vjs_menu_item.call(this, player, options); + super(player, options); this.off("click"); - }, - }); + } + } // Create the slide menu manager var vjs_menu_button = videojs.getComponent("MenuButton"); - var SlideButton = videojs.extend(vjs_menu_button, { - constructor: function (player, options) { + class SlideButton extends vjs_menu_button { + constructor(player, options) { options = options || {}; - vjs_menu_button.call(this, player, options); + super(player, options); this.addClass("vjs-slide-manager"); this.controlText(gettext("Open slide manager")); this.el().firstChild.firstChild.innerHTML = slide_mode_list[current_slide_mode]; - }, - createItems: function () { + } + createItems() { var items = []; items.push( @@ -412,7 +412,7 @@ var VideoSlides = function (items) { className: "vjs-menu-title vjs-slide-manager-title", innerHTML: gettext("Enrich mode"), }), - }) + }), ); for (let e in slide_mode_list) { @@ -420,26 +420,26 @@ var VideoSlides = function (items) { new SlideMode(player, { label: slide_mode_list[e], mode: e, - }) + }), ); } return items; - }, - }); + } + } var newbutton = new SlideButton(player); if (document.getElementsByClassName("vjs-slide-manager").length > 0) { document .getElementsByClassName("vjs-slide-manager")[0] .parentNode.removeChild( - document.getElementsByClassName("vjs-slide-manager")[0] + document.getElementsByClassName("vjs-slide-manager")[0], ); } player.controlBar .el() .insertBefore( newbutton.el(), - document.getElementsByClassName("vjs-fullscreen-control")[0] + document.getElementsByClassName("vjs-fullscreen-control")[0], ); }; ////VideoSLide construction (need to be and the end in order to register called methods) diff --git a/pod/enrichment/templates/enrichment/form_enrichment.html b/pod/enrichment/templates/enrichment/form_enrichment.html index 40f7a36202..eb687b32b3 100644 --- a/pod/enrichment/templates/enrichment/form_enrichment.html +++ b/pod/enrichment/templates/enrichment/form_enrichment.html @@ -7,7 +7,7 @@

{% trans 'Create / Edit enrichment' %} {% csrf_token %} -
+
{% if form_enrichment.errors or form_enrichment.non_field_errors %}
{% trans 'One or more errors have been found in the form:' %} diff --git a/pod/enrichment/templates/enrichment/group_enrichment.html b/pod/enrichment/templates/enrichment/group_enrichment.html index a865ac2145..feea78a96a 100644 --- a/pod/enrichment/templates/enrichment/group_enrichment.html +++ b/pod/enrichment/templates/enrichment/group_enrichment.html @@ -39,7 +39,7 @@

{% trans "Editing group for the enrichment of the video" %} "{{video.title}} {% endfor %} {% for field in form.visible_fields %} {% spaceless %} -
+
{{ field.errors }} diff --git a/pod/enrichment/templates/enrichment/list_enrichment.html b/pod/enrichment/templates/enrichment/list_enrichment.html index 185d7c1ca2..8d023ac8b6 100644 --- a/pod/enrichment/templates/enrichment/list_enrichment.html +++ b/pod/enrichment/templates/enrichment/list_enrichment.html @@ -26,13 +26,13 @@

{% trans 'List of the enrichments' %}&nbs {% csrf_token %} - +
{% csrf_token %} - +
diff --git a/pod/enrichment/templates/enrichment/video_enrichment-script.html b/pod/enrichment/templates/enrichment/video_enrichment-script.html index 48916639cb..6b5dd73027 100644 --- a/pod/enrichment/templates/enrichment/video_enrichment-script.html +++ b/pod/enrichment/templates/enrichment/video_enrichment-script.html @@ -6,8 +6,10 @@ const tracks = player.textTracks(); const trackElts = player.remoteTextTrackEls(); let metadataTrack, i, track; + for (i = 0; i < tracks.length; i++) { track = tracks[i]; + // console.log("track: ["+track.kind + "] " + track.label); if (track.kind === 'metadata' && track.label === 'enrichment') { metadataTrack = track; metadataTrack.index = i; @@ -17,6 +19,10 @@ } player.on('loadedmetadata', function() { + if(!metadataTrack){ + console.log("No enrichment track found.") + return + } let slide = []; if(!metadataTrack.cues) { //Safari do not get cues //let tracksrc = player.el().getElementsByTagName('TRACK')[metadataTrack.index].src; diff --git a/pod/enrichment/templates/enrichment/video_enrichment.html b/pod/enrichment/templates/enrichment/video_enrichment.html index 8ac576c6ed..d71e220803 100644 --- a/pod/enrichment/templates/enrichment/video_enrichment.html +++ b/pod/enrichment/templates/enrichment/video_enrichment.html @@ -19,7 +19,7 @@ - + @@ -32,7 +32,7 @@ - + @@ -56,21 +56,21 @@ {% block page_title %}{% if channel %}{{channel.title}} - {% endif %}{% if theme %}{{theme.title}} - {% endif %}({% trans 'Enriched' %}) {{video.title}}{% endblock %} -{% block video-element %} - -{% if form %} - {% include 'videos/video-form.html' %} -{% else %} - {% include 'enrichment/video-element-enrichment.html' %} -
{% include 'videos/video-all-info.html' with third_app=True %}
-{% endif %} - -{% endblock video-element %} +{% block page_content %} + {% block video-element %} + {% if form %} + {% include 'videos/video-form.html' %} + {% else %} + {% include 'enrichment/video-element-enrichment.html' %} + {% endif %} +
{% include 'videos/video-all-info.html' with third_app=True %}
+ {% endblock video-element %} +{% endblock page_content %} {% block page_aside %}

-  {% trans 'Informations' %} +  {% trans 'Informations' %}

{% trans 'To help you, the different types of enrichments have specific colors:' %}

diff --git a/pod/enrichment/tests/test_views.py b/pod/enrichment/tests/test_views.py index 44445c758e..58ec022046 100644 --- a/pod/enrichment/tests/test_views.py +++ b/pod/enrichment/tests/test_views.py @@ -43,7 +43,7 @@ def test_video_enrichment(self): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "enrichment/edit_enrichment.html") self.assertContains(response, "videotest") - self.assertContains(response, "list_enrich") + # self.assertContains(response, "list_enrich") print(" ---> test_video_enrichment: OK!") @@ -57,7 +57,7 @@ def test_video_enrichment_new(self): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "enrichment/edit_enrichment.html") self.assertContains(response, "videotest") - self.assertContains(response, "list_enrich") + # self.assertContains(response, "list_enrich") self.assertContains(response, "form_enrich") response = self.client.post( url, @@ -76,8 +76,8 @@ def test_video_enrichment_new(self): self.assertTrue(result) self.assertTemplateUsed("enrichment/edit_enrichment.html") self.assertContains(response, "videotest") - self.assertContains(response, "list_enrich") - self.assertContains(response, "testenrich") + # self.assertContains(response, "list_enrich") + # self.assertContains(response, "testenrich") print(" ---> test_video_enrichment_new: OK!") print(" [ END ENRICHMENT VIEWS ] ") @@ -120,8 +120,8 @@ def test_video_enrichment_edit(self): self.assertEqual(response.status_code, 200) self.assertTemplateUsed("enrichment/edit_enrichment.html") self.assertContains(response, "videotest") - self.assertContains(response, "list_enrich") - self.assertContains(response, "testenrich2") + # self.assertContains(response, "list_enrich") + # self.assertContains(response, "testenrich2") print(" ---> test_video_enrichment_edit: OK!") diff --git a/pod/favorite/admin.py b/pod/favorite/admin.py deleted file mode 100644 index 3d9c11705d..0000000000 --- a/pod/favorite/admin.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Esup-Pod Favorite video admin.""" -from django.contrib import admin -from .models import Favorite - - -@admin.register(Favorite) -class FavoriteAdmin(admin.ModelAdmin): - """Favorite video admin page.""" - - date_hierarchy = "date_added" - list_display = ( - "id", - "video", - "owner", - "date_added", - "rank", - ) - list_filter = ("owner", "date_added", "rank") - search_fields = ("video__title", "owner__username") diff --git a/pod/favorite/apps.py b/pod/favorite/apps.py deleted file mode 100644 index 14f97fa906..0000000000 --- a/pod/favorite/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Esup-Pod Favorite video app.""" -from django.apps import AppConfig -from django.utils.translation import gettext_lazy as _ - - -class FavoriteConfig(AppConfig): - """Favorite configuration app.""" - - name = "pod.favorite" - default_auto_field = "django.db.models.BigAutoField" - verbose_name = _("Favorite videos") diff --git a/pod/favorite/context_processors.py b/pod/favorite/context_processors.py deleted file mode 100644 index d7cc26d5c9..0000000000 --- a/pod/favorite/context_processors.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.conf import settings as django_settings - -USE_FAVORITES = getattr(django_settings, "USE_FAVORITES", True) - - -def context_settings(request): - """Return all context settings for favorite app""" - new_settings = {} - new_settings["USE_FAVORITES"] = USE_FAVORITES - return new_settings diff --git a/pod/favorite/models.py b/pod/favorite/models.py deleted file mode 100644 index 50f4045728..0000000000 --- a/pod/favorite/models.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Esup-Pod favorite video model.""" -from django.contrib.auth.models import User -from django.db import models -from django.utils import timezone -from django.utils.translation import ugettext as _ - -from pod.video.models import Video - - -class Favorite(models.Model): - """Favorite video model.""" - - video = models.ForeignKey(Video, verbose_name=_("Video"), on_delete=models.CASCADE) - owner = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE) - date_added = models.DateTimeField( - verbose_name=_("Date added"), default=timezone.now, editable=False - ) - rank = models.IntegerField(verbose_name=_("Rank"), editable=False) - - class Meta: - """Metadata for favorite video Model.""" - - constraints = [ - # A video can only be favorited once per owner - models.UniqueConstraint( - fields=["video", "owner"], name="unique_favorite_video_owner" - ), - # There mustn't be duplicated ranks for one owner - models.UniqueConstraint( - fields=["owner", "rank"], name="unique_favorite_owner_rank" - ), - ] - # Default ordering for Favorites items (not for Favorite video list) - ordering = ["owner", "rank"] - # Latest by ascending rank. - get_latest_by = "rank" - verbose_name = _("Favorite video") - verbose_name_plural = _("Favorite videos") - - def __str__(self) -> str: - """Display a favorite object as string.""" - return f"{self.owner} - favorite {self.rank} - {self.video}" diff --git a/pod/favorite/static/css/favorites-list.css b/pod/favorite/static/css/favorites-list.css deleted file mode 100644 index df865ed661..0000000000 --- a/pod/favorite/static/css/favorites-list.css +++ /dev/null @@ -1,59 +0,0 @@ -.shake-effect { - animation: shake 2.3s ease-in-out; - animation-iteration-count: infinite; - cursor: grab; -} - -.shake-effect .badge{ - display: block; -} - - -.shake-effect-active { - cursor: grabbing; -} - -@keyframes shake { - 0% { transform: translate(1px, 1px) rotate(0deg); } - 10% { transform: translate(-1px, -2px) rotate(-1deg); } - 20% { transform: translate(-3px, 0px) rotate(1deg); } - 30% { transform: translate(3px, 2px) rotate(0deg); } - 40% { transform: translate(1px, -1px) rotate(1deg); } - 50% { transform: translate(-1px, 2px) rotate(-1deg); } - 60% { transform: translate(-3px, 1px) rotate(0deg); } - 70% { transform: translate(3px, 1px) rotate(-1deg); } - 80% { transform: translate(-1px, -1px) rotate(1deg); } - 90% { transform: translate(1px, 2px) rotate(0deg); } - 100% { transform: translate(1px, 2px) rotate(0deg); } -} - -.no-click { - pointer-events: none; -} - -#sortForm.no-click, #collapse-button.no-click { - opacity: 0; - transition: opacity .9s ease; -} - -.card-hidden { - display: none; -} - -.card-footer, -.draggable-container .badge { - transition: opacity 0.9s ease; -} - -.shake-effect .card-footer, -.draggable-container .badge { - opacity: 0; -} - -.shake-effect .badge { - opacity: 1; -} - -.dropzone-hover { - border: .2rem solid #1F7C85; -} diff --git a/pod/favorite/static/js/favorite-reorganize.js b/pod/favorite/static/js/favorite-reorganize.js deleted file mode 100644 index f7e09750d4..0000000000 --- a/pod/favorite/static/js/favorite-reorganize.js +++ /dev/null @@ -1,177 +0,0 @@ -const exchangedValues = []; -var infinite; - -addEventForReorganizedButton(); - -const sortSelectElement = document.getElementById("sort"); -const sortDirectionElement = document.getElementById("sort_direction"); - -const reorganizeButtonsSpanElement = - document.getElementById("reorganize-buttons"); -const collapseAsideElement = document.getElementById("collapseAside"); -const reorganizeButton = document.getElementById("reorganize-button"); -const refreshButton = document.getElementById("refresh-button"); - -document - .getElementById("sort_direction_label") - .addEventListener("click", changeReorganizeButtons); -sortSelectElement.addEventListener("change", changeReorganizeButtons); - -/** - * Add or remove the CSS class to make drop zone hover. - * @param {string} state State of style (`add` or `remove`). - * @param {Element} element Element to add CSS class. - */ -function addOrRemoveDropZoneHoverStyleClass(state, element) { - const className = "dropzone-hover"; - if (state === "add") { - element.classList.add(className); - } else { - element.classList.remove(className); - } -} - -/** - * Switch the 'reorganize' and 'sort by rank' buttons. - */ -function changeReorganizeButtons() { - if (sortSelectElement.value === "rank" && !sortDirectionElement.checked) { - reorganizeButton.classList.remove("d-none"); - refreshButton.classList.add("d-none"); - } else { - reorganizeButton.classList.add("d-none"); - refreshButton.classList.remove("d-none"); - } -} - -/** - * Add an event listener to the 'reorganize-button' element. - */ -function addEventForReorganizedButton() { - document - .getElementById("reorganize-button") - .addEventListener("click", function (event) { - const draggableElements = document.querySelectorAll( - ".draggable-container" - ); - draggableElements.forEach((draggableElement) => { - draggableElement.addEventListener("dragenter", (event) => { - addOrRemoveDropZoneHoverStyleClass("add", event.target); - }); - draggableElement.addEventListener("dragleave", (event) => { - addOrRemoveDropZoneHoverStyleClass("remove", event.target); - }); - draggableElement.addEventListener("drop", (event) => { - addOrRemoveDropZoneHoverStyleClass("remove", event.target); - }); - }); - if (this.id == "reorganize-button") { - event.preventDefault(); - activateDragAndDrop(); - this.id = "save-button"; - this.title = gettext("Save your reorganization"); - const iconElement = this.querySelector("i"); - const spanElement = this.querySelector("span"); - iconElement.classList.replace("bi-arrows-move", "bi-save"); - spanElement.textContent = gettext("Save"); - } else if (this.id == "save-button") { - document.getElementById("json-data").value = - convert2DTableToJson(exchangedValues); - } - }); - document - .getElementById("refresh-button") - .addEventListener("click", function (event) { - event.preventDefault(); - window.location.assign(window.location.href.split("?")[0]); - }); -} - -/** - * Clear and transfer data when drag event starts. - * @param {Event} event The name of the event. - */ -function onDragStart(event) { - event.dataTransfer.clearData(); - event.dataTransfer.setData("text/plain", event.target.id); - event.target.classList.toggle("shake-effect-active"); -} - -/** - * Prevent the default behavior of the element during the event. - * @param {Event} event The name of the event. - */ -function onDragOver(event) { - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; -} - -/** - * Performs a swap between the dragged elements when dropping. - * @param {Event} event The name of the event. - */ -function onDrop(event) { - event.preventDefault(); - const id = event.dataTransfer.getData("text"); - const draggableElement = document.getElementById(id); - const dropzone = event.target; - const child1 = draggableElement.children[0]; - const child2 = dropzone.children[0]; - draggableElement.classList.toggle("shake-effect-active"); - if (child1.id == child2.id) return; - const child1copy = child1.cloneNode(true); - const child2copy = child2.cloneNode(true); - draggableElement.appendChild(child2copy); - dropzone.appendChild(child1copy); - child1.remove(); - child2.remove(); - exchangedValues.push([child1.id, child2.id]); -} - -/** - * Activate the drag and drop in the page - */ -function activateDragAndDrop(parent) { - const draggableElements = document.querySelectorAll(".draggable-container"); - const cardFooterElements = document.querySelectorAll(".card-footer"); - const sortForm = document.getElementById("sortForm"); - draggableElements.forEach((draggableElement) => { - draggableElement.setAttribute("draggable", true); - draggableElement.addEventListener("dragstart", onDragStart); - draggableElement.addEventListener("dragover", onDragOver); - draggableElement.addEventListener("drop", onDrop); - draggableElement.classList.add("shake-effect"); - draggableElement.children[0].classList.add("no-click"); - }); - sortForm.classList.add("no-click"); - updateCollapseAside(); - infinite.removeLoader(); - document - .getElementById("cancel_btn_favorites_list") - .classList.remove("d-none"); -} - -/** - * Convert a 2D table into a JSON string representation. - * @param {Array} table The 2D table to convert. - * @returns {String} The JSON string representation. - */ -function convert2DTableToJson(table) { - const jsonObject = {}; - for (let i = 0; i < table.length; i++) { - jsonObject[i] = table[i]; - } - return JSON.stringify(jsonObject); -} - -/** - * Update collapse aside to help user. - */ -function updateCollapseAside() { - const collapseAside = document.querySelector( - "#collapseAside > div.card.card-body" - ); - collapseAside.remove(); - const helpInformations = document.querySelector("#card-sharedraftversion"); - helpInformations.classList.remove("card-hidden"); -} diff --git a/pod/favorite/static/js/video-favorites-card-delete.js b/pod/favorite/static/js/video-favorites-card-delete.js deleted file mode 100644 index 245182cf96..0000000000 --- a/pod/favorite/static/js/video-favorites-card-delete.js +++ /dev/null @@ -1,29 +0,0 @@ -document.addEventListener("DOMContentLoaded", function () { - const cards = document.getElementsByClassName("draggable-container"); - const title = document.getElementById("video_count"); - for (let card of cards) { - const form = card.querySelector(".favorite-button-form-card"); - form.addEventListener("submit", function (e) { - e.preventDefault(); - const formData = new FormData(form); - fetch(form.action, { - method: form.method, - body: formData, - }) - .then((response) => response.text()) // We take the HTML content of the response - .then((data) => { - const parser = new DOMParser(); - const html = parser.parseFromString(data, "text/html"); - card.remove(); - const title = document.getElementById("video_count"); - title.replaceWith(html.getElementById("video_count")); - document.title = html.title; - addEventForReorganizedButton(); - }) - .catch((error) => { - if (!(error instanceof TypeError)) - alert(gettext("The video could not be removed from favorites...")); - }); - }); - } -}); diff --git a/pod/favorite/static/js/video-favorites-card-list.js b/pod/favorite/static/js/video-favorites-card-list.js deleted file mode 100644 index 805b676e1e..0000000000 --- a/pod/favorite/static/js/video-favorites-card-list.js +++ /dev/null @@ -1,27 +0,0 @@ -document.addEventListener("DOMContentLoaded", function () { - const forms = document.getElementsByClassName("favorite-button-form-card"); - for (let form of forms) { - form.addEventListener("submit", function (e) { - e.preventDefault(); - const formData = new FormData(form); - fetch(form.action, { - method: form.method, - body: formData, - }) - .then((response) => { - response.text(); // We take the HTML content of the response - const button = form.querySelector(".star_btn > i"); - button.classList.toggle("bi-star-fill"); - button.classList.toggle("bi-star"); - if (button.classList.contains("bi-star-fill")) { - button.title = gettext("Remove from favorite"); - } else { - button.title = gettext("Add in favorite"); - } - }) - .catch((error) => { - alert(gettext("The deletion couldn't be completed...")); - }); - }); - } -}); diff --git a/pod/favorite/static/js/video-favorites.js b/pod/favorite/static/js/video-favorites.js deleted file mode 100644 index c3049662ca..0000000000 --- a/pod/favorite/static/js/video-favorites.js +++ /dev/null @@ -1,25 +0,0 @@ -document.addEventListener("DOMContentLoaded", function () { - const form = document.getElementById("favorite-button-form"); - if (form !== null) { - form.addEventListener("submit", function (e) { - e.preventDefault(); - const formData = new FormData(form); - fetch(form.action, { - method: form.method, - body: formData, - }) - .then((response) => response.text()) // We take the HTML content of the response - .then((data) => { - const parser = new DOMParser(); - const html = parser.parseFromString(data, "text/html"); - const updatedButton = html.querySelector(".star_btn"); - const button = document.querySelector(".star_btn"); - button.replaceWith(updatedButton); - }) - .catch((error) => { - console.log(error); - alert(gettext("The favorite couldn’t be completed…")); - }); - }); - } -}); diff --git a/pod/favorite/templates/favorite/favorite_video_list.html b/pod/favorite/templates/favorite/favorite_video_list.html deleted file mode 100644 index b69c7c53af..0000000000 --- a/pod/favorite/templates/favorite/favorite_video_list.html +++ /dev/null @@ -1,32 +0,0 @@ -{% load i18n %} -{% load static %} -{% spaceless %} -
- {% for video in videos %} -
-
- {% include "videos/card.html" %} -
{{ video.rank }}
-
-
- {% empty %} -
-

{% trans "Sorry, no video found." %}

-
- {% endfor %} -
-{% if videos.has_next %} - -{% endif %} - -{% endspaceless %} -{% block more_script %} - -{% endblock %} diff --git a/pod/favorite/templates/favorite/favorite_videos.html b/pod/favorite/templates/favorite/favorite_videos.html deleted file mode 100644 index 494c0f2c1a..0000000000 --- a/pod/favorite/templates/favorite/favorite_videos.html +++ /dev/null @@ -1,80 +0,0 @@ -{% extends 'base.html' %} -{% load i18n %} -{% load static %} - -{% block opengraph %}{% load video_filters %} - - - - - - - - -{% endblock %} - -{% block page_extra_head %} - -{% endblock page_extra_head %} - -{% block breadcrumbs %}{{ block.super }} {% endblock %} - -{% block page_content %} -
-

{% blocktrans count counter=count_videos %}{{ counter }} video found{% plural %}{{ counter }} videos found{% endblocktrans %}

- {% if count_videos > 1 %} -
-
- {% csrf_token %} - - - -  {% trans "Cancel" %} - -
-
-
- {% include "videos/video_sort_select.html" with favorite=True %} -
- {% endif %} -
- {% include 'loader.html' %} - {% include "favorite/favorite_video_list.html" %} -{% endblock page_content %} - -{% block page_aside %} - {% include 'videos/filter_aside.html' %} -
-

{% trans "Help - Drag & Drop" %}

-
-
    -
  1. {% trans "Select the video to move by clicking and holding." %}
  2. -
  3. {% trans "While holding down the video, drag it to the desired location." %}
  4. -
  5. {% trans "Drop the video on another to swap their position." %}
  6. -
-
-
-{% endblock page_aside %} - - -{% block more_script %} - - - - - - -{% endblock more_script %} diff --git a/pod/favorite/templatetags/__init__.py b/pod/favorite/templatetags/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pod/favorite/templatetags/favorite_info.py b/pod/favorite/templatetags/favorite_info.py deleted file mode 100644 index c9ac9e50d5..0000000000 --- a/pod/favorite/templatetags/favorite_info.py +++ /dev/null @@ -1,39 +0,0 @@ -from django.template import Library - -from pod.video.models import Video - -from ..utils import user_has_favorite_video, get_number_favorites - -register = Library() - - -@register.simple_tag(takes_context=True, name="is_favorite") -def is_favorite(context: dict, video: Video) -> bool: - """ - Template tag to check if the user has this video as favorite. - - Args: - context (dict): The template context dictionary - video (:class:`pod.video.models.Video`): The video entity to check - - Returns: - bool: True if the user has the video as favorite, False otherwise - """ - request = context["request"] - if not request.user.is_authenticated: - return False - return user_has_favorite_video(request.user, video) - - -@register.simple_tag(name="number_favorites") -def number_favorites(video: Video) -> int: - """ - Template tag to get the favorite number. - - Args: - video (:class:`pod.video.models.Video`): The video entity - - Returns: - int: The video favorite number - """ - return get_number_favorites(video) diff --git a/pod/favorite/tests/__init__.py b/pod/favorite/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pod/favorite/tests/test_models.py b/pod/favorite/tests/test_models.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pod/favorite/tests/test_utils.py b/pod/favorite/tests/test_utils.py deleted file mode 100644 index 7bd446cc4e..0000000000 --- a/pod/favorite/tests/test_utils.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Unit tests for Esup-Pod favorite video utilities.""" - -from django.contrib.auth.models import User -from django.http import HttpRequest -from django.test import TestCase - -from pod.favorite.models import Favorite -from pod.favorite.utils import get_next_rank, user_add_or_remove_favorite_video -from pod.favorite.utils import user_has_favorite_video, get_number_favorites -from pod.favorite.utils import get_all_favorite_videos_for_user -from pod.video.utils import sort_videos_list -from pod.video.models import Type, Video - - -class FavoriteTestUtils(TestCase): - """TestCase for Esup-Pod favorite video utilities.""" - - fixtures = ["initial_data.json"] - - def setUp(self) -> None: - """Set up required objects for next tests.""" - self.user = User.objects.create(username="pod", password="pod1234pod") - self.user2 = User.objects.create(username="pod2", password="pod1234pod2") - self.video = Video.objects.create( - title="Video1", - owner=self.user, - video="test.mp4", - is_draft=False, - type=Type.objects.get(id=1), - ) - self.video2 = Video.objects.create( - title="Video2", - owner=self.user, - video="test2.mp4", - is_draft=False, - type=Type.objects.get(id=1), - ) - self.video3 = Video.objects.create( - title="Video3", - owner=self.user2, - video="test3.mp4", - is_draft=False, - type=Type.objects.get(id=1), - ) - - def test_next_rank(self) -> None: - """Test if get_next_rank works correctly""" - Favorite.objects.create( - owner=self.user, - video=self.video, - rank=1, - ) - self.assertEqual( - 2, - get_next_rank(self.user), - "Test if user with favorite can generate the next rank", - ) - self.assertEqual( - 1, - get_next_rank(self.user2), - "Test if user without favorite can generate the next rank", - ) - print(" ---> test_next_rank ok") - - def test_user_add_or_remove_favorite_video(self) -> None: - """Test if test_user_add_or_remove_favorite_video works correctly""" - user_add_or_remove_favorite_video(self.user, self.video) - favorite_tuple_exists = Favorite.objects.filter( - owner=self.user, video=self.video - ).exists() - self.assertTrue( - favorite_tuple_exists, - "Test if tuple has been correctly inserted", - ) - user_add_or_remove_favorite_video(self.user, self.video) - favorite_tuple_not_exists = Favorite.objects.filter( - owner=self.user, video=self.video - ).exists() - self.assertFalse( - favorite_tuple_not_exists, - "Test if tuple has been correctly deleted", - ) - print(" ---> test_user_add_or_remove_favorite_video ok") - - def test_user_has_favorite_video(self) -> None: - """Test if test_user_has_favorite_video works correctly""" - Favorite.objects.create( - owner=self.user, - video=self.video, - rank=1, - ) - self.assertTrue( - user_has_favorite_video(self.user, self.video), - "Test if user has a favorite video", - ) - self.assertFalse( - user_has_favorite_video(self.user2, self.video), - "Test if user hasn't a favorite video", - ) - print(" ---> test_user_has_favorite_video ok") - - def test_get_number_favorites(self) -> None: - """Test if test_get_number_favorites works correctly""" - self.assertEqual( - get_number_favorites(self.video), - 0, - "Test if there's no favorites in the video", - ) - Favorite.objects.create( - owner=self.user, - video=self.video, - rank=1, - ) - Favorite.objects.create( - owner=self.user2, - video=self.video, - rank=1, - ) - self.assertEqual( - get_number_favorites(self.video), - 2, - "Test if there is 2 favorites in the video", - ) - - print(" ---> test_get_number_favorites ok") - - def test_get_all_favorite_videos_for_user(self) -> None: - """Test if get_all_favorite_videos_for_user works correctly.""" - Favorite.objects.create( - owner=self.user, - video=self.video, - rank=1, - ) - video_list = get_all_favorite_videos_for_user(self.user) - self.assertEqual(video_list.count(), 1) - self.assertEqual(video_list.first(), self.video) - print(" ---> get_all_favorite_videos_for_user ok") - - def test_sort_videos_list_1(self) -> None: - """Test if sort_videos_list works correctly.""" - request = HttpRequest() - Favorite.objects.create( - owner=self.user, - video=self.video, - rank=1, - ) - Favorite.objects.create( - owner=self.user, - video=self.video2, - rank=2, - ) - Favorite.objects.create( - owner=self.user, - video=self.video3, - rank=3, - ) - - sorted_videos = [self.video3, self.video2, self.video] - test_sorted_videos = sort_videos_list( - get_all_favorite_videos_for_user(self.user), request.GET.get("sort", "rank") - ) - self.assertEqual(list(test_sorted_videos), sorted_videos) - - request.GET["sort"] = "rank" - request.GET["sort_direction"] = "on" - sorted_videos = [self.video, self.video2, self.video3] - test_sorted_videos = sort_videos_list( - get_all_favorite_videos_for_user(self.user), - request.GET.get("sort", "rank"), - request.GET.get("sort_direction"), - ) - self.assertEqual(list(test_sorted_videos), sorted_videos) - - print(" ---> sort_videos_list ok") diff --git a/pod/favorite/tests/test_views.py b/pod/favorite/tests/test_views.py deleted file mode 100644 index cba7713bea..0000000000 --- a/pod/favorite/tests/test_views.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Esup-Pod favorite views tests. - -* run with 'python manage.py test pod.favorite.tests.test_views' -""" -from django.test import override_settings, TestCase -from django.contrib.auth.models import User -from django.urls import reverse -from django.utils.translation import ugettext_lazy as _ - -from pod.favorite import context_processors -from pod.favorite.models import Favorite -from pod.video.models import Type, Video - -import importlib - - -class TestShowStarTestCase(TestCase): - """Favorite star icon test case.""" - - fixtures = ["initial_data.json"] - - def setUp(self) -> None: - """Set up required objects for next tests.""" - self.user_with_favorite = User.objects.create( - username="pod", password="pod1234pod" - ) - self.user_without_favorite = User.objects.create( - username="pod2", password="pod1234pod2" - ) - self.video = Video.objects.create( - title="Video1", - owner=self.user_without_favorite, - video="test.mp4", - is_draft=False, - type=Type.objects.get(id=1), - ) - Favorite.objects.create( - owner=self.user_with_favorite, - video=self.video, - rank=1, - ) - self.url = reverse("video:video", args=[self.video.slug]) - - @override_settings(USE_FAVORITES=True) - def test_show_star_unfill(self) -> None: - """Test if the star is unfill when the video isn't favorite.""" - importlib.reload(context_processors) - self.client.force_login(self.user_without_favorite) - response = self.client.get(self.url) - self.assertEqual( - response.status_code, - 200, - "Test if status code equal 200 when the video isn't favorite", - ) - self.assertTrue( - "bi-star" in response.content.decode(), - "Test if the star is correctly present when the video isn't favorite", - ) - self.client.logout() - print(" ---> test_show_star_unfill ok") - - @override_settings(USE_FAVORITES=True) - def test_show_star_fill(self) -> None: - """Test if the star is filled when the video is favorite.""" - importlib.reload(context_processors) - self.client.force_login(self.user_with_favorite) - response = self.client.get(self.url) - self.assertEqual( - response.status_code, - 200, - "Test if status code equal 200 when the video is favorite", - ) - self.assertTrue( - "bi-star-fill" in response.content.decode(), - "Test if the star is correctly present when the video is favorite", - ) - self.client.logout() - print(" ---> test_show_star_fill ok") - - @override_settings(USE_FAVORITES=True) - def test_favorite_star_hidden(self) -> None: - """Test if the favorite star is hidden when the user is disconnected.""" - importlib.reload(context_processors) - response = self.client.get(self.url) - self.assertEqual( - response.status_code, - 200, - "Test if status code equal 200 when the user is disconnected", - ) - self.assertFalse( - 'class="btn btn-lg btn-link p-1 star_btn"' in response.content.decode(), - "Test if the star does not appear when the user is disconnected", - ) - print(" ---> test_favorite_star_hidden ok") - - def test_show_star_404_error(self) -> None: - """Test if we can't navigate in the `favorite/` route with GET method.""" - importlib.reload(context_processors) - response = self.client.get(reverse("favorite:add-or-remove")) - self.assertEqual( - response.status_code, - 404, - """ - Test if status code equal 404 when we try to navigate in - the `favorite/` route with GET method - """, - ) - print(" ---> test_show_star_404_error ok") - - @override_settings(USE_FAVORITES=False) - def test_show_star_when_use_favorites_equal_false(self) -> None: - """Test if the star isn't present when USE_FAVORITES equal False.""" - importlib.reload(context_processors) - self.client.force_login(self.user_without_favorite) - response = self.client.get(self.url) - self.assertEqual( - response.status_code, - 200, - "Test if status code equal 200 when USE_FAVORITES equal False", - ) - self.assertFalse( - "bi-star" in response.content.decode(), - "Test if the star isn't present when USE_FAVORITES equal False", - ) - self.client.logout() - print(" ---> test_show_star_when_use_favorites_equal_false ok") - - -class TestFavoriteVideoListTestCase(TestCase): - """Favorite video list test case.""" - - fixtures = ["initial_data.json"] - - def setUp(self) -> None: - """Set up required objects for next tests.""" - self.user_with_favorite = User.objects.create( - username="pod", password="pod1234pod" - ) - self.user_without_favorite = User.objects.create( - username="pod2", password="pod1234pod2" - ) - self.video = Video.objects.create( - title="Video1", - owner=self.user_without_favorite, - video="test.mp4", - is_draft=False, - type=Type.objects.get(id=1), - ) - Favorite.objects.create( - owner=self.user_with_favorite, - video=self.video, - rank=1, - ) - self.url = reverse("favorite:list") - - @override_settings(USE_FAVORITES=True) - def test_favorite_video_list_not_empty(self) -> None: - """Test if the favorite video list isn't empty when the user has favorites.""" - importlib.reload(context_processors) - self.client.force_login(self.user_with_favorite) - response = self.client.get(self.url) - self.assertEqual( - response.status_code, - 200, - "Test if status code equal 200 when the favorite video list isn't empty", - ) - self.assertTrue( - 'data-countvideos="1"' in response.content.decode(), - "Test if the favorite video list isn't correctly empty", - ) - self.client.logout() - print(" ---> test_favorite_video_list_not_empty ok") - - @override_settings(USE_FAVORITES=True) - def test_favorite_video_list_empty(self) -> None: - """Test if the favorite video list is empty when the user has favorites.""" - importlib.reload(context_processors) - self.client.force_login(self.user_without_favorite) - response = self.client.get(self.url) - self.assertEqual( - response.status_code, - 200, - "Test if status code equal 200 when the favorite video list isn't empty", - ) - self.assertTrue( - 'data-countvideos="0"' in response.content.decode(), - "Test if the favorite video list is correctly empty", - ) - self.client.logout() - print(" ---> test_favorite_video_list_empty ok") - - @override_settings(USE_FAVORITES=True) - def test_favorite_video_list_link_in_navbar(self) -> None: - """Test if the favorite video list link is present in the navbar.""" - importlib.reload(context_processors) - self.client.force_login(self.user_with_favorite) - response = self.client.get("/") - self.assertEqual( - response.status_code, - 200, - "Test if status code equal 200 in test_favorite_video_list_link_in_navbar", - ) - self.assertTrue( - str(_("My favorite videos")) in response.content.decode(), - "Test if the favorite video list link is present in the navbar", - ) - self.client.logout() - print(" ---> test_favorite_video_list_link_in_navbar ok") - - @override_settings(USE_FAVORITES=False) - def test_favorite_video_list_link_in_navbar_when_use_favorites_is_false(self) -> None: - """Test if the favorite video list link is present in the navbar.""" - importlib.reload(context_processors) - self.client.force_login(self.user_with_favorite) - response = self.client.get("/") - self.assertEqual( - response.status_code, - 200, - """ - Test if status code equal 200 in - test_favorite_video_list_link_in_navbar_when_use_favorites_equal_false - """, - ) - self.assertFalse( - str(_("My favorite videos")) in response.content.decode(), - "Test if the favorite video list link is present in the navbar", - ) - self.client.logout() - print( - """ - ---> test_favorite_video_list_link_in_navbar_when_use_favorites_equal_false - ok - """ - ) - - -class TestShowStarInfoTestCase(TestCase): - """Favorite star info test case.""" - - fixtures = ["initial_data.json"] - - def setUp(self) -> None: - """Set up required objects for next tests.""" - self.user = User.objects.create(username="pod", password="pod1234pod") - self.video = Video.objects.create( - title="Video1", - owner=self.user, - video="test.mp4", - is_draft=False, - type=Type.objects.get(id=1), - ) - self.url = reverse("video:video", args=[self.video.slug]) - - @override_settings(USE_FAVORITES=True) - def test_favorites_count(self) -> None: - """Test favorite count.""" - importlib.reload(context_processors) - response = self.client.get(self.url) - self.assertEqual( - response.status_code, - 200, - "Test if status code equal 200 when the user is disconnected", - ) - self.assertTrue( - str(_("Number of favorites")) in response.content.decode(), - "Test if the counter is in video_info", - ) - print(" ---> test_favorites_count ok") diff --git a/pod/favorite/urls.py b/pod/favorite/urls.py deleted file mode 100644 index d8459b8135..0000000000 --- a/pod/favorite/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -from django.urls import path - -from .views import favorite_button_in_video_info, favorite_list -from .views import favorites_save_reorganisation - -app_name = "favorite" - -urlpatterns = [ - path("", favorite_button_in_video_info, name="add-or-remove"), - path("list/", favorite_list, name="list"), - path( - "save-reorganisation/", favorites_save_reorganisation, name="save-reorganisation" - ), -] diff --git a/pod/favorite/utils.py b/pod/favorite/utils.py deleted file mode 100644 index 301b27de8c..0000000000 --- a/pod/favorite/utils.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Esup-Pod favorite video utilities.""" -from django.contrib.auth.models import User -from django.db.models import Max - -from .models import Favorite -from pod.video.models import Video - - -def user_has_favorite_video(user: User, video: Video) -> bool: - """ - Know if user has the video in favorite. - - Args: - user (:class:`django.contrib.auth.models.User`): The user entity - video (:class:`pod.video.models.Video`): The video entity - - Returns: - bool: True if user has the video in favorite, False otherwise - """ - return Favorite.objects.filter(owner=user, video=video).exists() - - -def user_add_or_remove_favorite_video(user: User, video: Video): - """ - Add or remove the video in favorite list of the user. - - Args: - user (:class:`django.contrib.auth.models.User`): The user entity - video (:class:`pod.video.models.Video`): The video entity - """ - if user_has_favorite_video(user, video): - Favorite.objects.filter(owner=user, video=video).delete() - else: - Favorite.objects.create(owner=user, video=video, rank=get_next_rank(user)) - - -def get_next_rank(user: User) -> int: - """ - Get the next favorite rank for the user. - - Args: - user (:class:`django.contrib.auth.models.User`): The user entity - - Returns: - int: The next rank - """ - last_rank = Favorite.objects.filter(owner=user).aggregate(Max("rank"))["rank__max"] - return last_rank + 1 if last_rank is not None else 1 - - -def get_number_favorites(video: Video): - """Return how much a video has been favorited.""" - return Favorite.objects.filter(video=video).count() - - -def get_all_favorite_videos_for_user(user: User) -> list: - """ - Get all favorite videos for a specific user. - - Args: - user (:class:`django.contrib.auth.models.User`): The user entity - - Returns: - list(:class:`pod.video.models.Video`): The video list - """ - favorite_id = Favorite.objects.filter(owner=user).values_list("video_id", flat=True) - video_list = Video.objects.filter(id__in=favorite_id).extra( - select={"rank": "favorite_favorite.rank"}, - tables=["favorite_favorite"], - where=[ - "favorite_favorite.video_id=video_video.id", - "favorite_favorite.owner_id=%s", - ], - params=[user.id], - ) - return video_list diff --git a/pod/favorite/views.py b/pod/favorite/views.py deleted file mode 100644 index d9c08906d2..0000000000 --- a/pod/favorite/views.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Esup-Pod favorite video Views.""" -from django.contrib.sites.shortcuts import get_current_site -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db import transaction -from django.http import Http404, HttpResponseBadRequest -from django.shortcuts import get_object_or_404, redirect, render -from django.views.decorators.csrf import csrf_protect -from django.contrib.auth.decorators import login_required -from django.utils.translation import ugettext_lazy as _ - -from pod.favorite.models import Favorite -from pod.main.utils import is_ajax -from pod.video.models import Video -from pod.video.views import CURSUS_CODES, get_owners_has_instances -from pod.video.utils import sort_videos_list - -from .utils import user_add_or_remove_favorite_video -from .utils import get_all_favorite_videos_for_user - -import json - - -@csrf_protect -def favorite_button_in_video_info(request): - """Add or remove favorite video when the user click on star button.""" - if request.method == "POST": - video = get_object_or_404( - Video, pk=request.POST.get("video"), sites=get_current_site(request) - ) - if video.is_draft: - return False - user_add_or_remove_favorite_video(request.user, video) - return redirect(request.META["HTTP_REFERER"]) - else: - raise Http404() - - -@login_required(redirect_field_name="referrer") -def favorite_list(request): - """Render the main list of favorite videos.""" - sort_field = request.GET.get("sort", "rank") - sort_direction = request.GET.get("sort_direction") - videos_list = sort_videos_list( - get_all_favorite_videos_for_user(request.user), sort_field, sort_direction - ) - count_videos = len(videos_list) - - page = request.GET.get("page", 1) - full_path = "" - if page: - full_path = ( - request.get_full_path() - .replace("?page=%s" % page, "") - .replace("&page=%s" % page, "") - ) - - paginator = Paginator(videos_list, 12) - try: - videos = paginator.page(page) - except PageNotAnInteger: - videos = paginator.page(1) - except EmptyPage: - videos = paginator.page(paginator.num_pages) - - ownersInstances = get_owners_has_instances(request.GET.getlist("owner")) - - if is_ajax(request): - return render( - request, - "favorite/favorite_video_list.html", - {"videos": videos, "full_path": full_path, "count_videos": count_videos}, - ) - - return render( - request, - "favorite/favorite_videos.html", - { - "page_title": _("My favorite videos"), - "videos": videos, - "count_videos": count_videos, - "types": request.GET.getlist("type"), - "owners": request.GET.getlist("owner"), - "disciplines": request.GET.getlist("discipline"), - "tags_slug": request.GET.getlist("tag"), - "cursus_selected": request.GET.getlist("cursus"), - "full_path": full_path, - "ownersInstances": ownersInstances, - "cursus_list": CURSUS_CODES, - "sort_field": sort_field, - "sort_direction": sort_direction, - }, - ) - - -@csrf_protect -def favorites_save_reorganisation(request): - """Save reorganization when the user click on save button.""" - if request.method == "POST": - json_data = request.POST.get("json-data") - try: - dict_data = json.loads(json_data) - except json.JSONDecodeError: - return HttpResponseBadRequest("JSON au mauvais format") - with transaction.atomic(): - for videos_tuple in dict_data.values(): - fav_video_1 = Favorite.objects.filter( - owner_id=request.user.id, - video_id=Video.objects.only("id").get(slug=videos_tuple[0]).id, - ) - fav_video_2 = Favorite.objects.filter( - owner_id=request.user.id, - video_id=Video.objects.only("id").get(slug=videos_tuple[1]).id, - ) - video_1_rank = fav_video_1[0].rank - video_2_rank = fav_video_2[0].rank - fav_video_1.update(rank=video_2_rank) - fav_video_2.update(rank=video_1_rank) - - return redirect(request.META["HTTP_REFERER"]) - else: - raise Http404() diff --git a/pod/import_video/forms.py b/pod/import_video/forms.py index cadc7829f6..86ed8fb9a4 100644 --- a/pod/import_video/forms.py +++ b/pod/import_video/forms.py @@ -42,7 +42,7 @@ class ExternalRecordingForm(forms.ModelForm): def filter_fields_admin(form): """List fields, depends on user right.""" - if form.is_superuser is False and form.is_admin is False: + if not form.is_superuser and not form.is_admin: form.remove_field("owner") form.remove_field("site") diff --git a/pod/import_video/static/css/import_video.css b/pod/import_video/static/css/import_video.css index 65712d8656..68c7fed335 100644 --- a/pod/import_video/static/css/import_video.css +++ b/pod/import_video/static/css/import_video.css @@ -26,12 +26,7 @@ border-radius: 50%; width: 50px; height: 50px; - animation: recording-spin 3s linear infinite; -} - -@-webkit-keyframes recording-spin { - 0% { -webkit-transform: rotate(0deg); } - 100% { -webkit-transform: rotate(360deg); } + animation: recording-spin 3s linear infinite; } @keyframes recording-spin { @@ -40,7 +35,7 @@ } /* */ /* Message error */ -div.alert { +div.alert.alert-dismissible { border-radius: 6px; display: table; width: 100%; diff --git a/pod/import_video/templates/import_video/add_or_edit.html b/pod/import_video/templates/import_video/add_or_edit.html index 76cfc0f642..36dc5e9985 100644 --- a/pod/import_video/templates/import_video/add_or_edit.html +++ b/pod/import_video/templates/import_video/add_or_edit.html @@ -187,28 +187,28 @@

{% trans "Terms of Service

{% trans "It is necessary to respect the terms of use of the various services before being able to upload a video from their site to this platform." %}

- -
- -
- -
+

{% trans "Their terms of service state that you are not allowed to download any content unless permitted by YouTube or the person who owns the copyright to the content." %}

{% trans "YouTube's Terms of Service" %}

@@ -239,12 +239,14 @@

{% trans "Terms of Service parents[parents.length-1].style.display = 'none'; } }; -window.addEventListener('load', function(event) { - is_restricted_elt = document.getElementById("id_is_restricted"); - is_restricted_elt.addEventListener('clicked',function (event) { +is_restricted_elt = document.getElementById("id_is_restricted"); +if (is_restricted_elt !== null) { + window.addEventListener('load', function(event) { + is_restricted_elt.addEventListener('clicked',function (event) { + restrict_access_to_groups(); + }); restrict_access_to_groups(); }); - restrict_access_to_groups(); -}); +} {% endblock more_script %} diff --git a/pod/import_video/templates/import_video/list.html b/pod/import_video/templates/import_video/list.html index e3792dd50b..33f7bfd01f 100644 --- a/pod/import_video/templates/import_video/list.html +++ b/pod/import_video/templates/import_video/list.html @@ -8,7 +8,7 @@ {% endblock more_style %} -{% block breadcrumbs %}{{ block.super }} +{% block breadcrumbs %}{{ block.super }} {% endblock %} @@ -86,7 +86,7 @@ {% endif %} - {%endfor%} + {% endfor %} {% endblock page_content %} diff --git a/pod/import_video/utils.py b/pod/import_video/utils.py index 422afa71ea..a70b55b05e 100644 --- a/pod/import_video/utils.py +++ b/pod/import_video/utils.py @@ -1,13 +1,18 @@ """Utils for Meeting and Import_video module.""" +import json import requests import shutil from datetime import datetime as dt from django.conf import settings +from django.utils.html import mark_safe from django.utils.translation import gettext_lazy as _ from html.parser import HTMLParser from pod.video.models import Video from pod.video.models import Type +from urllib.parse import parse_qs, urlparse + +MAX_UPLOAD_SIZE_ON_IMPORT = getattr(settings, "MAX_UPLOAD_SIZE_ON_IMPORT", 4) DEFAULT_TYPE_ID = getattr(settings, "DEFAULT_TYPE_ID", 1) @@ -58,10 +63,11 @@ def secure_request_for_upload(request): raise ValueError(msg) -def parse_remote_file(source_html_url): +def parse_remote_file(session, source_html_url): """Parse the remote HTML file on the BBB server. Args: + session (Session) : session useful to achieve requests (and keep cookies between) source_html_url (String): URL to parse Raises: @@ -71,7 +77,7 @@ def parse_remote_file(source_html_url): String: name of the video found in the page """ try: - response = requests.get(source_html_url) + response = session.get(source_html_url) if response.status_code != 200: msg = {} msg["error"] = _( @@ -79,7 +85,7 @@ def parse_remote_file(source_html_url): ) # If we want to display the 404/500... page to the user # msg["message"] = response.content.decode("utf-8") - msg["message"] = "Error number : %s" % response.status_code + msg["message"] = _("Error number: %s") % response.status_code raise ValueError(msg) # Parse the BBB video HTML file @@ -97,7 +103,7 @@ def parse_remote_file(source_html_url): if extension not in VIDEO_ALLOWED_EXTENSIONS: msg = {} msg["error"] = _( - "The video file for this recording was not " "found in the HTML file." + "The video file for this recording was not found in the HTML file." ) msg["message"] = _("The found file is not a valid video.") raise ValueError(msg) @@ -109,21 +115,96 @@ def parse_remote_file(source_html_url): msg["error"] = _( "The video file for this recording was not found in the HTML file." ) - msg["message"] = _("No video file found") + msg["message"] = _("No video file found.") raise ValueError(msg) except Exception as exc: msg = {} msg["error"] = _( "The video file for this recording was not found in the HTML file." ) - msg["message"] = str(exc) + msg["message"] = mark_safe(str(exc)) raise ValueError(msg) -def download_video_file(source_video_url, dest_file): +def manage_recording_url(source_url, video_file_add): + """Generate the BBB video URL. + + See more explanations in manage_download() function. + + Args: + source_url (String): Source file URL + video_file_add (String): Name of the video file to add to the URL + + Returns: + String: good URL of a BBB recording video + """ + try: + bbb_playback_video = "/video/" + url = urlparse(source_url) + if url.query: + query = parse_qs(url.query, keep_blank_values=True) + if query["token"][0]: + # 1st case (ex: ESR URL), URL likes (ex for ESR URL:) + # https://_site_/recording/_recording_id/video?token=_token_ + # Get recording unique identifier + recording_id = url.path.split("/")[2] + # Define 2nd video URL + # Ex: https://_site_/video/_recording_id/video-0.m4v + source_video_url = "%s://%s%s%s/%s" % ( + url.scheme, + url.netloc, + bbb_playback_video, + recording_id, + video_file_add, + ) + else: + # 2nd case (BBB URL standard without token) + source_video_url = source_url + video_file_add + return source_video_url + else: + return source_url + video_file_add + except Exception: + return source_url + video_file_add + + +def manage_download(session, source_url, video_file_add, dest_file): + """Manage the download of a BBB video file. + + 2 possibilities : + - Download BBB video file directly. + - Download BBB video file where source URL is protected by a single-use token. + In such a case, 2 requests are made, using the same session. + A cookie is set at the first request (the parsing one, called before) + and used for the second one. + + This function is a simple shortcut to the calls of manage_recording_url + and download_video_file. + + Args: + session (Session) : session useful to achieve requests (and keep cookies between) + source_url (String): Source file URL + video_file_add (String): Name of the video file to add to the URL + dest_file (String): Destination file of the Pod video + + Returns: + source_video_url (String) : video source file URL + + Raises: + ValueError: if impossible download + """ + try: + source_video_url = manage_recording_url(source_url, video_file_add) + download_video_file(session, source_video_url, dest_file) + return source_video_url + except Exception as exc: + raise ValueError(mark_safe(str(exc))) + + +def download_video_file(session, source_video_url, dest_file): """Download BBB video file. Args: + session (Session) : session useful to achieve requests (and keep cookies between) source_video_url (String): Video file URL dest_file (String): Destination file of the Pod video @@ -132,17 +213,16 @@ def download_video_file(source_video_url, dest_file): """ # Check if video file exists try: - with requests.get(source_video_url, timeout=(10, 180), stream=True) as response: + with session.get(source_video_url, timeout=(10, 180), stream=True) as response: + # Can be useful to debug + # print(session.cookies.get_dict()) if response.status_code != 200: - msg = {} - msg["error"] = _( - "The video file for this recording " - "was not found on the BBB server." + raise ValueError( + _( + "The video file for this recording " + "was not found on the BBB server." + ) ) - # If we want to display the 404/500... page to the user - # msg["message"] = response.content.decode("utf-8") - msg["message"] = "Error number : %s" % response.status_code - raise ValueError(msg) with open(dest_file, "wb+") as file: # Total size, in bytes, from response header @@ -156,18 +236,15 @@ def download_video_file(source_video_url, dest_file): # Method 3 : The fastest shutil.copyfileobj(response.raw, file) except Exception as exc: - msg = {} - msg["error"] = _("Impossible to download the video file from the server.") - msg["message"] = str(exc) - raise ValueError(msg) + raise ValueError(mark_safe(str(exc))) -def save_video(request, dest_file, recording_name, description, date_evt=None): +def save_video(request, dest_path, recording_name, description, date_evt=None): """Save and encode the Pod video file. Args: request (Request): HTTP request - dest_file (String): Destination file of the Pod video + dest_path (String): Destination path of the Pod video recording_name (String): recording name description (String): description added to the Pod video date_evt (Datetime, optional): Event date. Defaults to None. @@ -177,7 +254,7 @@ def save_video(request, dest_file, recording_name, description, date_evt=None): """ try: video = Video.objects.create( - video=dest_file, + video=dest_path, title=recording_name, owner=request.user, description=description, @@ -191,7 +268,7 @@ def save_video(request, dest_file, recording_name, description, date_evt=None): except Exception as exc: msg = {} msg["error"] = _("Impossible to create the Pod video") - msg["message"] = str(exc) + msg["message"] = mark_safe(str(exc)) raise ValueError(msg) @@ -204,13 +281,54 @@ def check_file_exists(source_url): Returns: Boolean: file exists (True) or not (False) """ - response = requests.head(source_url) + response = requests.head(source_url, timeout=2) if response.status_code < 400: return True else: return False +def verify_video_exists_and_size(video_url): + """Check that the video file exists and its size does not exceed the limit. + + Args: + video_url (String): Video source URL + + Raises: + ValueError: exception raised if no video found in this URL or video oversized + """ + response = requests.head(video_url, timeout=2) + if response.status_code < 400: + # Video file size + size = int(response.headers.get("Content-Length", "0")) + check_video_size(size) + else: + msg = {} + msg["error"] = _("No video file found.") + msg["message"] = _("No video file found for this address.") + raise ValueError(msg) + + +def check_video_size(video_size): + """Check that the video file size does not exceed the limit. + + Args: + video_size (Integer): Video file size + + Raises: + ValueError: exception raised if video oversized + """ + size_max = int(MAX_UPLOAD_SIZE_ON_IMPORT) * 1024 * 1024 * 1024 + if MAX_UPLOAD_SIZE_ON_IMPORT != 0 and video_size > size_max: + msg = {} + msg["error"] = _("File too large.") + msg["message"] = ( + _("The size of the video file exceeds the maximum allowed value, %s Gb.") + % MAX_UPLOAD_SIZE_ON_IMPORT + ) + raise ValueError(msg) + + class video_parser(HTMLParser): """Useful to parse the BBB Web page and search for video file. @@ -298,3 +416,14 @@ def get_end_time(self): def get_duration(self): """Return duration.""" return str(self.get_end_time() - self.get_start_time()).split(".")[0] + + def to_json(self): + """Return recording data (without uploadedToPodBy) in JSON format.""" + exclusion_list = ["uploadedToPodBy"] + return json.dumps( + { + key: value + for key, value in self.__dict__.items() + if key not in exclusion_list + } + ) diff --git a/pod/import_video/views.py b/pod/import_video/views.py index 7ebd0794bd..6dd324fd3c 100644 --- a/pod/import_video/views.py +++ b/pod/import_video/views.py @@ -7,8 +7,10 @@ from .models import ExternalRecording from .forms import ExternalRecordingForm -from .utils import StatelessRecording, download_video_file, check_file_exists -from .utils import save_video, secure_request_for_upload, parse_remote_file +from .utils import StatelessRecording, check_file_exists, download_video_file +from .utils import manage_recording_url, parse_remote_file +from .utils import save_video, secure_request_for_upload +from .utils import check_video_size, verify_video_exists_and_size from datetime import datetime from django.conf import settings from django.contrib.sites.shortcuts import get_current_site @@ -23,6 +25,7 @@ from django.utils.text import get_valid_filename from django.views.decorators.csrf import csrf_protect from django.views.decorators.csrf import ensure_csrf_cookie +from pod.import_video.utils import manage_download from pod.main.views import in_maintenance from pod.main.utils import secure_post_request, display_message_with_icon @@ -63,6 +66,8 @@ ), ) +VIDEOS_DIR = getattr(settings, "VIDEOS_DIR", "videos") + def secure_external_recording(request, recording): """Secure an external recording. @@ -421,46 +426,70 @@ def upload_bbb_recording_to_pod(request, record_id): Boolean: True if upload achieved """ try: + # Session useful to achieve requests (and keep cookies between) + session = requests.Session() + recording = ExternalRecording.objects.get(id=record_id) source_url = request.POST.get("source_url") - # Step 1 : Download and parse the remote HTML file if necessary + # Step 1: Download and parse the remote HTML file if necessary # Check if extension is a video extension + """ extension = source_url.split(".")[-1].lower() if extension in VIDEO_ALLOWED_EXTENSIONS: # URL corresponds to a video file source_video_url = source_url else: # Download and parse the remote HTML file - video_file = parse_remote_file(source_url) + video_file = parse_remote_file(session, source_url) source_video_url = source_url + video_file + """ + + # Check if extension is a video extension + extension = source_url.split(".")[-1].lower() + # Name of the video file to add to the URL (if necessary) + video_file_add = "" + if extension not in VIDEO_ALLOWED_EXTENSIONS: + # Download and parse the remote HTML file + video_file_add = parse_remote_file(session, source_url) + # Extension overload + extension = video_file_add.split(".")[-1].lower() - # Step 2 : Define destination source file + # Verify that video exists and not oversised + source_video_url = manage_recording_url(source_url, video_file_add) + verify_video_exists_and_size(source_video_url) + + # Step 2: Define destination source file extension = source_video_url.split(".")[-1].lower() discrim = datetime.now().strftime("%Y%m%d%H%M%S") dest_file = os.path.join( settings.MEDIA_ROOT, - "videos", + VIDEOS_DIR, request.user.owner.hashkey, os.path.basename("%s-%s.%s" % (discrim, recording.id, extension)), ) - os.makedirs(os.path.dirname(dest_file), exist_ok=True) - # Step 3 : Download the video file - download_video_file(source_video_url, dest_file) + dest_path = os.path.join( + VIDEOS_DIR, + request.user.owner.hashkey, + os.path.basename("%s-%s.%s" % (discrim, recording.id, extension)), + ) + + # Step 3: Download the video file + source_video_url = manage_download(session, source_url, video_file_add, dest_file) - # Step 4 : Save informations about the recording + # Step 4: Save informations about the recording recording_title = request.POST.get("recording_name") save_external_recording(request, record_id) - # Step 5 : Save and encode Pod video + # Step 5: Save and encode Pod video description = _( - "This video was uploaded to Pod; its origin is %(type)s : " + "This video was uploaded to Pod; its origin is %(type)s: " '%(url)s' ) % {"type": recording.get_type_display(), "url": source_video_url} - save_video(request, dest_file, recording_title, description) + save_video(request, dest_path, recording_title, description) return True except Exception as exc: @@ -468,7 +497,7 @@ def upload_bbb_recording_to_pod(request, record_id): msg["error"] = _("Impossible to upload to Pod the video") try: # Management of error messages from sub-functions - message = "%s (%s)" % (exc.args[0]["error"], exc.args[0]["message"]) + message = "%s %s" % (exc.args[0]["error"], exc.args[0]["message"]) except Exception: # Management of error messages in all cases message = str(exc) @@ -484,7 +513,7 @@ def upload_youtube_recording_to_pod(request, record_id): """Upload Youtube recording to Pod. Use PyTube with its API - More information : https://pytube.io/en/latest/api.html + More information: https://pytube.io/en/latest/api.html Args: request (Request): HTTP request record_id (Integer): id record in the database @@ -506,17 +535,20 @@ def upload_youtube_recording_to_pod(request, record_id): # use_oauth=True, # allow_oauth_cache=True ) - # Publish date (format : 2023-05-13 00:00:00) - # Event date (format : 2023-05-13) + # Publish date (format: 2023-05-13 00:00:00) + # Event date (format: 2023-05-13) date_evt = str(yt_video.publish_date)[0:10] # Setting video resolution yt_stream = yt_video.streams.get_highest_resolution() + # Verify that video not oversized + check_video_size(yt_stream.filesize) + # User directory dest_dir = os.path.join( settings.MEDIA_ROOT, - "videos", + VIDEOS_DIR, request.user.owner.hashkey, ) os.makedirs(os.path.dirname(dest_dir), exist_ok=True) @@ -524,24 +556,25 @@ def upload_youtube_recording_to_pod(request, record_id): discrim = datetime.now().strftime("%Y%m%d%H%M%S") filename = "%s-%s" % (discrim, get_valid_filename(yt_stream.default_filename)) # Video file path - dest_file = os.path.join( - dest_dir, + dest_path = os.path.join( + VIDEOS_DIR, + request.user.owner.hashkey, filename, ) # Download video yt_stream.download(dest_dir, filename=filename) - # Step 4 : Save informations about the recording + # Step 4: Save informations about the recording save_external_recording(request, record_id) - # Step 5 : Save and encode Pod video + # Step 5: Save and encode Pod video description = _( "This video '%(name)s' was uploaded to Pod; " - 'its origin is Youtube : %(url)s' + 'its origin is Youtube: %(url)s' ) % {"name": yt_video.title, "url": source_url} recording_title = request.POST.get("recording_name") - save_video(request, dest_file, recording_title, description, date_evt) + save_video(request, dest_path, recording_title, description, date_evt) return True except VideoUnavailable: @@ -569,7 +602,7 @@ def upload_youtube_recording_to_pod(request, record_id): msg["error"] = _("Impossible to upload to Pod the video") try: # Management of error messages from sub-functions - message = "%s (%s)" % (exc.args[0]["error"], exc.args[0]["message"]) + message = "%s %s" % (exc.args[0]["error"], exc.args[0]["message"]) except Exception: # Management of error messages in all cases message = str(exc) @@ -584,7 +617,7 @@ def upload_youtube_recording_to_pod(request, record_id): def upload_peertube_recording_to_pod(request, record_id): # noqa: C901 """Upload Peertube recording to Pod. - More information : https://docs.joinpeertube.org/api/rest-getting-started + More information: https://docs.joinpeertube.org/api/rest-getting-started Args: request (Request): HTTP request record_id (Integer): id record in the database @@ -596,17 +629,20 @@ def upload_peertube_recording_to_pod(request, record_id): # noqa: C901 Boolean: True if upload achieved """ try: + # Session useful to achieve requests (and keep cookies between) + session = requests.Session() + # Manage source URL from video playback source_url = request.POST.get("source_url") # Check if extension is a video extension extension = source_url.split(".")[-1].lower() if extension in VIDEO_ALLOWED_EXTENSIONS: - # URL corresponds to a video file. Format example : + # URL corresponds to a video file. Format example: # - https://xxxx.fr/download/videos/id-quality.mp4 - # with : id = id/uuid/shortUUID, quality=480/720/1080 + # with: id = id/uuid/shortUUID, quality=480/720/1080 source_video_url = source_url - # PeerTube API for this video : + # PeerTube API for this video: # https://xxxx.fr/api/v1/videos/id pos_pt = source_url.rfind("-") if pos_pt != -1: @@ -622,11 +658,11 @@ def upload_peertube_recording_to_pod(request, record_id): # noqa: C901 msg["proposition"] = _("Try changing the address of the recording.") raise ValueError(msg) else: - # URL corresponds to a PeerTube URL. Format example : + # URL corresponds to a PeerTube URL. Format example: # - https://xxx.fr/w/id # - https://xxx.fr/videos/watch/id - # with : id = id/uuid/shortUUID - # PeerTube API for this video : + # with: id = id/uuid/shortUUID + # PeerTube API for this video: # https://xxxx.fr/api/v1/videos/id url_api_video = source_url.replace("/w/", "/api/v1/videos/") url_api_video = url_api_video.replace("/videos/watch/", "/api/v1/videos/") @@ -655,38 +691,47 @@ def upload_peertube_recording_to_pod(request, record_id): # noqa: C901 pt_video_description = "" else: pt_video_description = pt_video_description.replace("\r\n", "
") - # Creation date (format : 2023-05-23T08:16:34.690Z) + # Creation date (format: 2023-05-23T08:16:34.690Z) pt_video_created_at = pt_video_json["createdAt"] - # Evant date (format : 2023-05-23) + # Evant date (format: 2023-05-23) date_evt = pt_video_created_at[0:10] # Source video file source_video_url = pt_video_json["files"][0]["fileDownloadUrl"] - # Step 2 : Define destination source file + # Verify that video exists and not oversized + verify_video_exists_and_size(source_video_url) + + # Step 2: Define destination source file discrim = datetime.now().strftime("%Y%m%d%H%M%S") extension = source_video_url.split(".")[-1].lower() dest_file = os.path.join( settings.MEDIA_ROOT, - "videos", + VIDEOS_DIR, request.user.owner.hashkey, os.path.basename("%s-%s.%s" % (discrim, pt_video_uuid, extension)), ) os.makedirs(os.path.dirname(dest_file), exist_ok=True) - # Step 3 : Download the video file - download_video_file(source_video_url, dest_file) + dest_path = os.path.join( + VIDEOS_DIR, + request.user.owner.hashkey, + os.path.basename("%s-%s.%s" % (discrim, pt_video_uuid, extension)), + ) + + # Step 3: Download the video file + download_video_file(session, source_video_url, dest_file) - # Step 4 : Save informations about the recording + # Step 4: Save informations about the recording recording_title = request.POST.get("recording_name") save_external_recording(request, record_id) - # Step 5 : Save and encode Pod video + # Step 5: Save and encode Pod video description = _( - "This video '%(name)s' was uploaded to Pod; its origin is PeerTube : " + "This video '%(name)s' was uploaded to Pod; its origin is PeerTube: " "%(url)s." ) % {"name": pt_video_name, "url": pt_video_url} description = ("%s
%s") % (description, pt_video_description) - save_video(request, dest_file, recording_title, description, date_evt) + save_video(request, dest_path, recording_title, description, date_evt) return True except Exception as exc: @@ -694,7 +739,7 @@ def upload_peertube_recording_to_pod(request, record_id): # noqa: C901 msg["error"] = _("Impossible to upload to Pod the PeerTube video") try: # Management of error messages from sub-functions - message = "%s (%s)" % (exc.args[0]["error"], exc.args[0]["message"]) + message = "%s %s" % (exc.args[0]["error"], exc.args[0]["message"]) except Exception: # Management of error messages in all cases message = str(exc) @@ -734,25 +779,27 @@ def get_stateless_recording(request, data): # Management of the external recording type if data.type == "bigbluebutton": + # Manage BBB recording URL + video_url = data.source_url # For BBB, external URL can be the video or presentation playback - if data.source_url.find("playback/video") != -1: + if video_url.find("playback/video") != -1: # Management for standards video URLs with BBB or Scalelite server - recording.videoUrl = data.source_url - elif data.source_url.find("playback/presentation/2.3") != -1: + recording.videoUrl = video_url + elif video_url.find("playback/presentation/2.3") != -1: # Management for standards presentation URLs with BBB or Scalelite server # Add computed video playback - recording.videoUrl = data.source_url.replace( + recording.videoUrl = video_url.replace( "playback/presentation/2.3", "playback/video" ) - recording.presentationUrl = data.source_url + recording.presentationUrl = video_url else: # Management of other situations, non standards URLs - recording.videoUrl = data.source_url + recording.videoUrl = video_url # For old BBB or BBB 2.6+ without video playback if check_file_exists(recording.videoUrl) is False: recording.state = _( - "No video file found. " "Upload to Pod as a video is not possible." + "No video file found. Upload to Pod as a video is not possible." ) recording.canUpload = False recording.videoUrl = "" diff --git a/pod/live/admin.py b/pod/live/admin.py index 2f6f8682a2..7a1316a06a 100644 --- a/pod/live/admin.py +++ b/pod/live/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from django.contrib.sites.models import Site from django.contrib.sites.shortcuts import get_current_site -from django.forms import Textarea +from django.urls import reverse from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ from js_asset import static @@ -118,16 +118,12 @@ def get_autocomplete_fields(self, request): return ["building"] def get_form(self, request, obj=None, **kwargs): - kwargs["widgets"] = { - "piloting_conf": Textarea( - attrs={ - "placeholder": "{\n 'server_url':'...',\n \ - 'application':'...',\n 'livestream':'...',\n}" - } - ) - } + form = super(BroadcasterAdmin, self).get_form(request, obj, **kwargs) + form.base_fields["piloting_conf"].widget.attrs.update( + {"data-url": reverse("live:ajax_get_mandatory_parameters") + "?impl_name="} + ) kwargs["help_texts"] = {"qrcode": _("QR code to record immediately an event")} - return super().get_form(request, obj, **kwargs) + return form def get_queryset(self, request): qs = super().get_queryset(request) @@ -156,6 +152,7 @@ class Media: } js = ( "js/main.js", + "js/admin_broadcaster.js", "podfile/js/filewidget.js", "bootstrap/dist/js/bootstrap.min.js", ) @@ -242,6 +239,7 @@ def get_thumbnail_admin(self, instance): "is_restricted", "password", "is_auto_start_admin", + "is_recording_stopped", "get_thumbnail_admin", "enable_transcription", ] diff --git a/pod/live/forms.py b/pod/live/forms.py index 47fbecc159..d1e4baba51 100644 --- a/pod/live/forms.py +++ b/pod/live/forms.py @@ -245,7 +245,7 @@ class EventForm(forms.ModelForm): ( "advanced_options", { - "legend": _("Display advanced options"), + "legend": _("Advanced options"), "classes": "collapse", "fields": [ "description", diff --git a/pod/live/management/commands/checkLiveStartStop.py b/pod/live/management/commands/checkLiveStartStop.py index fc716c1e67..903588070d 100644 --- a/pod/live/management/commands/checkLiveStartStop.py +++ b/pod/live/management/commands/checkLiveStartStop.py @@ -8,9 +8,12 @@ from pod.live.models import Event from pod.live.views import ( - is_recording, + can_manage_stream, event_stoprecord, event_startrecord, + is_recording, + start_stream, + stop_stream, ) DEFAULT_EVENT_PATH = getattr(settings, "DEFAULT_EVENT_PATH", "") @@ -37,66 +40,114 @@ def handle(self, *args, **options): self.debug_mode = False self.stdout.write( - f"- Beginning at {datetime.now().strftime('%H:%M:%S')}", ending="" + f"== Beginning at {datetime.now().strftime('%H:%M:%S')} ", ending="" ) - self.stdout.write(" - IN DEBUG MODE -" if self.debug_mode else "") + self.stdout.write("IN DEBUG MODE ==" if self.debug_mode else "==") self.stop_finished() self.start_new() - self.stdout.write("- End -") + self.stdout.write(f"== End at {datetime.now().strftime('%H:%M:%S')} ==") + self.stdout.write("") def stop_finished(self): - self.stdout.write("-- Stopping finished events (if started with Pod):") + """ + Stop all the recording of today's already finished events but yet not stopped. + Including the non auto-started events (to be sure they are not forgotten). + """ + self.stdout.write("- Stopping finished events (if started with Pod) -") + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) zero_now = timezone.now().replace(second=0, microsecond=0) - # events ending now - events = Event.objects.filter(end_date=zero_now) - for event in events: - if not is_recording(event.broadcaster, True): - continue + events = Event.objects.filter( + Q(end_date__gte=today) + & Q(end_date__lte=zero_now) + & Q(is_recording_stopped=False) + ).order_by("end_date") + for event in events: self.stdout.write( - f"Broadcaster {event.broadcaster.name} should be stopped: ", ending="" + f"Event : '{event.slug}', " f"on Broadcaster '{event.broadcaster_id}' ", + ending="", ) + if not is_recording(event.broadcaster, True): + event.is_recording_stopped = True + event.save() + self.stdout.write("is already stopped") + continue + if self.debug_mode: - self.stdout.write("... but not tried (debug mode) ") + self.stdout.write("should be stopped ... but not tried (debug mode) ") continue + self.stdout.write("should be stopped") + response = event_stoprecord(event.id, event.broadcaster.id) if json.loads(response.content)["success"]: - self.stdout.write(" ... stopped ") + self.stdout.write(" -> Record stopped ") else: - self.stderr.write(" ... fail to stop recording") + self.stderr.write(" -> Fail to stop recording") + + self.close_stream(event.broadcaster) def start_new(self): - self.stdout.write("-- Starting new events:") + """ + Starts all recording of the current events + that are auto-started configured and not stopped by manager. + """ + self.stdout.write("- Starting new events -") events = Event.objects.filter( Q(is_auto_start=True) + & Q(is_recording_stopped=False) & Q(start_date__lte=timezone.now()) & Q(end_date__gt=timezone.now()) ) for event in events: - if is_recording(event.broadcaster): - self.stdout.write( - f"Broadcaster {event.broadcaster.name} is already recording" - ) - continue - self.stdout.write( - f"Broadcaster {event.broadcaster.name} should be started: ", ending="" + f"Event : '{event.slug}', " f"on Broadcaster '{event.broadcaster_id}'", + ending="", ) + if is_recording(event.broadcaster): + self.stdout.write("is already recording") + continue + if self.debug_mode: - self.stdout.write("... but not tried (debug mode) ") + self.stdout.write("should be started ... but not tried (debug mode) ") continue - if event_startrecord(event.id, event.broadcaster.id): - self.stdout.write(" ... successfully started") + self.stdout.write("should be started") + + self.open_stream(event.broadcaster) + + response = event_startrecord(event.id, event.broadcaster.id) + if json.loads(response.content)["success"]: + self.stdout.write(" -> Record successfully started") + else: + self.stderr.write(" -> Fail to start record") + + def open_stream(self, broadcaster): + """Try to open the broadcaster stream.""" + if can_manage_stream(broadcaster): + if start_stream(broadcaster): + self.stdout.write("RTMP stream started") + else: + self.stderr.write("RTMP stream not started") + else: + self.stdout.write("Stream is not RTMP (will not try to start)") + + def close_stream(self, broadcaster): + """Try to close the broadcaster stream.""" + if can_manage_stream(broadcaster): + started = stop_stream(broadcaster) + if started: + self.stdout.write("RTMP stream stopped") else: - self.stderr.write(" ... fail to start") + self.stderr.write("RTMP stream not stopped") + else: + self.stdout.write("Stream is not RTMP (will not try to stop)") diff --git a/pod/live/models.py b/pod/live/models.py index 66a9a4ed5c..804ff795fc 100644 --- a/pod/live/models.py +++ b/pod/live/models.py @@ -51,7 +51,10 @@ LANG_CHOICES = getattr( settings, "LANG_CHOICES", - ((" ", __PREF_LANG_CHOICES__), ("----------", __ALL_LANG_CHOICES__)), + ( + (_("-- Frequently used languages --"), __PREF_LANG_CHOICES__), + (_("-- All languages --"), __ALL_LANG_CHOICES__), + ), ) MEDIA_URL = getattr(settings, "MEDIA_URL", "/media/") LIVE_TRANSCRIPTIONS_FOLDER = getattr( @@ -432,6 +435,9 @@ class Event(models.Model): help_text=_("If this box is checked, the record will start automatically."), default=False, ) + is_recording_stopped = models.BooleanField( + default=False, + ) video_on_hold = models.ForeignKey( Video, help_text=_("This video will be displayed when there is no live stream."), diff --git a/pod/live/pilotingInterface.py b/pod/live/pilotingInterface.py index 171ef70d24..b149a2b42d 100644 --- a/pod/live/pilotingInterface.py +++ b/pod/live/pilotingInterface.py @@ -3,23 +3,40 @@ import logging import os import re -import requests - from abc import ABC as __ABC__, abstractmethod from datetime import timedelta -from typing import Optional -from django.conf import settings +from typing import Optional, List -from .models import Broadcaster +import paramiko +import requests +from django.conf import settings +from django.http import JsonResponse, HttpResponseNotAllowed -__EXISTING_BROADCASTER_IMPLEMENTATIONS__ = ["Wowza"] +from .models import Broadcaster, Event +from .utils import date_string_to_second DEFAULT_EVENT_PATH = getattr(settings, "DEFAULT_EVENT_PATH", "") logger = logging.getLogger(__name__) +__EXISTING_BROADCASTER_IMPLEMENTATIONS__ = ["Wowza", "SMP"] + +__MANDATORY_PARAMETERS__ = { + "Wowza": {"server_url", "application", "livestream"}, + "SMP": { + "server_url", + "sftp_port", + "user", + "password", + "record_dir_path", + "rtmp_streamer_id", + }, +} + class PilotingInterface(__ABC__): + """Class to be implemented for any device (with an Api) we want to control in the event's page.""" + @abstractmethod def __init__(self, broadcaster: Broadcaster): """Initialize the PilotingInterface @@ -27,53 +44,152 @@ def __init__(self, broadcaster: Broadcaster): self.broadcaster = broadcaster raise NotImplementedError + @abstractmethod + def copy_file_needed(self) -> bool: + """If the video file needs to be copied from a remote server.""" + raise NotImplementedError + + @abstractmethod + def can_split(self) -> bool: + """If the split function can be executed.""" + raise NotImplementedError + @abstractmethod def check_piloting_conf(self) -> bool: - """Checks the piloting conf value""" + """Checks the piloting conf value.""" raise NotImplementedError @abstractmethod def is_available_to_record(self) -> bool: - """Checks if the broadcaster is available""" + """Checks if the broadcaster is available.""" raise NotImplementedError @abstractmethod def is_recording(self, with_file_check=False) -> bool: - """Checks if the broadcaster is being recorded - :param with_file_check: - checks if tmp recording file is present on the filesystem - (recording could have been launch from somewhere else) + """ + Returns if the broadcaster is recording state. + Args: + with_file_check(bool): checks if tmp recording file is present on the filesystem, + as recording could have been launch from somewhere else. """ raise NotImplementedError @abstractmethod - def start(self, event_id, login=None) -> bool: - """Start the recording""" + def start_recording(self, event_id, login=None) -> bool: + """Start the recording.""" raise NotImplementedError @abstractmethod - def split(self) -> bool: - """Split the current record""" + def split_recording(self) -> bool: + """Split the current record.""" raise NotImplementedError @abstractmethod - def stop(self) -> bool: - """Stop the recording""" + def stop_recording(self) -> bool: + """Stop the recording.""" raise NotImplementedError @abstractmethod def get_info_current_record(self) -> dict: - """Get info of current record""" + """Get info of current record.""" raise NotImplementedError @abstractmethod def copy_file_to_pod_dir(self, filename) -> bool: - """Copy the file from remote server to pod server""" + """Copy the file from remote server to pod server.""" raise NotImplementedError + @abstractmethod + def can_manage_stream(self) -> bool: + """If the stream can be started and stopped.""" + raise NotImplementedError + + @abstractmethod + def start_stream(self) -> bool: + """Starts the stream.""" + raise NotImplementedError + + @abstractmethod + def stop_stream(self) -> bool: + """Stops the streams.""" + raise NotImplementedError + + @abstractmethod + def get_stream_rtmp_infos(self) -> dict: + """Checks if SMP is configured for Rtmp and gets infos.""" + raise NotImplementedError + + +def ajax_get_mandatory_parameters(request): + """Returns the mandatory parameters as a json response.""" + if request.method == "GET" and request.is_ajax(): + impl_name = request.GET.get("impl_name", None) + params = get_mandatory_parameters(impl_name) + params_json = {} + for value in params: + params_json[value] = "..." + + return JsonResponse(data=params_json) + + return HttpResponseNotAllowed(["GET"]) + + +def get_mandatory_parameters(impl_name="") -> List[str]: + """Returns the mandatory parameters of the implementation.""" + if impl_name in __MANDATORY_PARAMETERS__: + return __MANDATORY_PARAMETERS__[impl_name] + if impl_name.lower() in __MANDATORY_PARAMETERS__: + return __MANDATORY_PARAMETERS__[impl_name.lower()] + if impl_name.title() in __MANDATORY_PARAMETERS__: + return __MANDATORY_PARAMETERS__[impl_name.title()] + if impl_name.upper() in __MANDATORY_PARAMETERS__: + return __MANDATORY_PARAMETERS__[impl_name.upper()] + return [""] + + +def validate_json_implementation(broadcaster: Broadcaster) -> bool: + """Returns if the config value is json formatted and has all the mandatory parameters.""" + conf = broadcaster.piloting_conf + if not conf: + logger.error( + "'piloting_conf' value is not set for '" + broadcaster.name + "' broadcaster." + ) + return False + try: + decoded = json.loads(conf) + except Exception as e: + logger.error( + "'piloting_conf' has not a valid Json format for '" + + broadcaster.name + + "' broadcaster. " + + str(e) + ) + return False + + parameters = get_mandatory_parameters(broadcaster.piloting_implementation) + + if not parameters <= decoded.keys(): + mandatory = "" + for value in parameters: + mandatory += "'" + value + "':'...'," + logger.error( + "'piloting_conf' format value for '" + + broadcaster.name + + "' broadcaster must be like : " + + "{" + + mandatory[:-1] + + "}" + ) + return False + + return True + def get_piloting_implementation(broadcaster) -> Optional[PilotingInterface]: - logger.debug("get_piloting_implementation") + """Returns the class inheriting from PilotingInterface according to the broadcaster configuration (or None).""" + if broadcaster is None: + return None + piloting_impl = broadcaster.piloting_implementation if not piloting_impl: logger.info( @@ -82,6 +198,7 @@ def get_piloting_implementation(broadcaster) -> Optional[PilotingInterface]: + "' broadcaster." ) return None + map_interface = map(str.lower, __EXISTING_BROADCASTER_IMPLEMENTATIONS__) if not piloting_impl.lower() in map_interface: logger.warning( @@ -105,21 +222,32 @@ def get_piloting_implementation(broadcaster) -> Optional[PilotingInterface]: ) return Wowza(broadcaster) + if piloting_impl.lower() == "smp": + logger.debug( + "piloting_implementation found : '" + + piloting_impl.lower() + + "' for broadcaster : '" + + broadcaster.name + + "'" + ) + return Smp(broadcaster) + logger.warning("->get_piloting_implementation - This should not happen.") return None def is_recording_launched_by_pod(self) -> bool: - # Récupération du fichier associé à l'enregistrement + """Returns if the current recording has been launched by Pod.""" + # Fetch file name of current recording current_record_info = self.get_info_current_record() - if not current_record_info.get("currentFile"): + filename = current_record_info.get("currentFile", None) + if not filename: logger.error(" ... impossible to get recording file name") return False - filename = current_record_info.get("currentFile") full_file_name = os.path.join(DEFAULT_EVENT_PATH, filename) - # Vérification qu'il existe bien pour cette instance ce Pod + # Check if this file exists in Pod filesystem if not os.path.exists(full_file_name): logger.debug(" ... is not on this POD recording filesystem: " + full_file_name) return False @@ -140,38 +268,20 @@ def __init__(self, broadcaster: Broadcaster): application=conf["application"], ) - def check_piloting_conf(self) -> bool: - logger.debug("Wowza - Check piloting conf") - conf = self.broadcaster.piloting_conf - if not conf: - logger.error( - "'piloting_conf' value is not set for '" - + self.broadcaster.name - + "' broadcaster." - ) - return False - try: - decoded = json.loads(conf) - except Exception: - logger.error( - "'piloting_conf' has not a valid Json format for '" - + self.broadcaster.name - + "' broadcaster." - ) - return False - if not {"server_url", "application", "livestream"} <= decoded.keys(): - logger.error( - "'piloting_conf' format value for '" - + self.broadcaster.name - + "' broadcaster must be like: " - "{'server_url':'...','application':'...','livestream':'...'}" - ) - return False + def copy_file_needed(self) -> bool: + """Implement copy_file_needed from PilotingInterface.""" + return False - logger.debug("->piloting conf OK") + def can_split(self) -> bool: + """Implement can_split from PilotingInterface.""" return True + def check_piloting_conf(self) -> bool: + """Implement check_piloting_conf from PilotingInterface.""" + return validate_json_implementation(self.broadcaster) + def is_available_to_record(self) -> bool: + """Implement is_available_to_record from PilotingInterface.""" logger.debug("Wowza - Check availability") json_conf = self.broadcaster.piloting_conf conf = json.loads(json_conf) @@ -194,6 +304,7 @@ def is_available_to_record(self) -> bool: return False def is_recording(self, with_file_check=False) -> bool: + """Implement is_recording from PilotingInterface.""" logger.debug("Wowza - Check if is being recorded") json_conf = self.broadcaster.piloting_conf conf = json.loads(json_conf) @@ -218,17 +329,16 @@ def is_recording(self, with_file_check=False) -> bool: else: return True - def start(self, event_id=None, login=None) -> bool: + def start_recording(self, event_id, login=None) -> bool: + """Implement start_recording from PilotingInterface.""" logger.debug("Wowza - Start record") json_conf = self.broadcaster.piloting_conf conf = json.loads(json_conf) url_start_record = ( self.url + "/instances/_definst_/streamrecorders/" + conf["livestream"] ) - filename = self.broadcaster.slug - if event_id is not None: - filename = str(event_id) + "_" + filename - elif login is not None: + filename = str(event_id) + "_" + self.broadcaster.slug + if login is not None: filename = login + "_" + filename data = { "instanceName": "", @@ -295,11 +405,13 @@ def execute_action(self, action) -> bool: return False - def split(self) -> bool: + def split_recording(self) -> bool: """Split the recording.""" - return self.execute_action("splitRecording") + if self.can_split(): + return self.execute_action("splitRecording") + return False - def stop(self) -> bool: + def stop_recording(self) -> bool: """Stop the recording.""" return self.execute_action("stopRecording") @@ -330,12 +442,12 @@ def get_info_current_record(self): current_file = response.json().get("currentFile") try: - ending = current_file.split("_")[-1] + ending = current_file.split("_")[-1] if current_file else "" if re.match(r"\d+\.", ending): number = ending.split(".")[0] if int(number) > 0: segment_number = number - except Exception: + except IndexError: pass segment_duration = response.json().get("segmentDuration", 0) @@ -348,4 +460,302 @@ def get_info_current_record(self): } def copy_file_to_pod_dir(self, filename): + """Implement copy_file_to_pod_dir from PilotingInterface.""" + return False + + def can_manage_stream(self) -> bool: + """Implement can_manage_stream from PilotingInterface.""" + return False + + def start_stream(self) -> bool: + """Implement start_stream from PilotingInterface.""" + return False + + def stop_stream(self) -> bool: + """Implement stop_stream from PilotingInterface.""" + return False + + def get_stream_rtmp_infos(self) -> dict: + """Implement get_stream_rtmp_infos from PilotingInterface.""" + return {} + + +class Smp(PilotingInterface): + def __init__(self, broadcaster: Broadcaster): + self.broadcaster = broadcaster + self.url = None + if self.check_piloting_conf(): + conf = json.loads(self.broadcaster.piloting_conf) + url = "{server_url}/api/swis/resources" + self.url = url.format( + server_url=conf["server_url"], + # smp_version=conf["smp_version"], + ) + + def copy_file_needed(self) -> bool: + """Implement copy_file_needed from PilotingInterface.""" + return True + + def can_split(self) -> bool: + """Implement can_split from PilotingInterface.""" + return False + + def check_piloting_conf(self) -> bool: + """Implement check_piloting_conf from PilotingInterface.""" + return validate_json_implementation(self.broadcaster) + + def is_available_to_record(self) -> bool: + """Implement is_available_to_record from PilotingInterface.""" + logger.debug("Smp - Check availability") + json_conf = self.broadcaster.piloting_conf + conf = json.loads(json_conf) + url_state_live_stream_recording = self.url + "?uri=/record/state" + + response = requests.get( + url_state_live_stream_recording, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + auth=(conf["user"], conf["password"]), + ) + + return self.verify_smp_response(response, "result", "stopped") + + def is_recording(self, with_file_check=False) -> bool: + """Implement is_recording from PilotingInterface.""" + logger.debug("Smp - Check if is being recorded") + json_conf = self.broadcaster.piloting_conf + conf = json.loads(json_conf) + url_state_live_stream_recording = self.url + "?uri=/record/state" + + response = requests.get( + url_state_live_stream_recording, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + auth=(conf["user"], conf["password"]), + ) + + return self.verify_smp_response(response, "result", "recording") + + def start_recording(self, event_id, login=None) -> bool: + """Implement start_recording from PilotingInterface.""" + logger.debug("Smp - Start record") + json_conf = self.broadcaster.piloting_conf + conf = json.loads(json_conf) + url_stop_record = self.url + + event = Event.objects.filter(id=event_id).first() + filename = event.slug if event else str(event_id) + "_" + self.broadcaster.slug + login = login if login else "unknown" + body = json.dumps( + [ + {"uri": "/record/1/root_dir_fs", "value": "internal"}, + { + "uri": "/record/control", + "value": { + "recording": "record", + "location": "internal", + "metadata": { + "title": filename, + "creator": login, + "description": "launch from Pod", + }, + }, + }, + ] + ) + response = requests.put( + url_stop_record, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + auth=(conf["user"], conf["password"]), + data=body, + ) + return self.verify_smp_response(response, "recording", "record") + + def split_recording(self) -> bool: + """Implement split_recording from PilotingInterface.""" + logger.error("Smp - Split record - should not be called") + return False + + def stop_recording(self) -> bool: + """Implement stop_recording from PilotingInterface.""" + logger.debug("Smp - Stop_record") + + json_conf = self.broadcaster.piloting_conf + conf = json.loads(json_conf) + url_stop_record = self.url + response = requests.put( + url_stop_record, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + auth=(conf["user"], conf["password"]), + data=json.dumps([{"uri": "/record/control", "value": "stop"}]), + ) + return self.verify_smp_response(response, "result", "stop") + + def get_info_current_record(self): + """Implement get_info_current_record from PilotingInterface.""" + logger.debug("Smp - Get info from current record") + json_conf = self.broadcaster.piloting_conf + conf = json.loads(json_conf) + url_info_live_stream = self.url + "?uri=/record" + + response = requests.get( + url_info_live_stream, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + auth=(conf["user"], conf["password"]), + ) + + if ( + response.status_code != http.HTTPStatus.OK + or not response.json() + or response.json()[0].get("result", "") == "" + ): + logger.warning("get_info_current_record in error") + return { + "currentFile": "", + "segmentNumber": "", + "outputPath": "", + "durationInSeconds": "", + } + + infos = response.json()[0].get("result") + + return { + "segmentNumber": "", + "currentFile": infos.get("filename"), + "outputPath": infos.get("root_dir_fs"), + "durationInSeconds": date_string_to_second(infos.get("elapsed_time")), + } + + def copy_file_to_pod_dir(self, filename): + """Implement copy_file_to_pod_dir from PilotingInterface.""" + logger.debug("Smp - Copy file to Pod dir") + + json_conf = self.broadcaster.piloting_conf + conf = json.loads(json_conf) + + smp_file_dir = conf["record_dir_path"] + + # because filename can be a path + file_head_tail = os.path.split(filename) + if file_head_tail[0]: + smp_file_path = smp_file_dir + filename + else: + smp_file_path = os.path.join(smp_file_dir, filename) + + # SFTP Server Credentials + ftp_host = re.sub("https?://", "", conf["server_url"]) + ftp_user = conf["user"] + ftp_pwd = conf["password"] + ftp_port = int(conf["sftp_port"]) + + try: + # connection to SFTP Server + t = paramiko.Transport((ftp_host, ftp_port)) + t.connect(None, ftp_user, ftp_pwd) + sftp = paramiko.SFTPClient.from_transport(t) + logger.debug("-- connection ok ") + + # where the file will be stored + pod_file_name = file_head_tail[1] + pod_file_path = os.path.join(DEFAULT_EVENT_PATH, pod_file_name) + logger.debug( + "-- try to copy from SMP : " + + smp_file_path + + " to Pod : " + + pod_file_path + ) + + # copy from remote to local + sftp.get(smp_file_path, pod_file_path) + logger.debug("-- copied!") + + # close the connection + logger.debug("-- closing connection") + sftp.close() + return True + except OSError as e: + logger.error("Failed to copy file over SFTP : " + str(e)) + return False + + def can_manage_stream(self) -> bool: + """Implement can_manage_stream from PilotingInterface.""" + return True + + def start_stream(self) -> bool: + """Implement start_stream from PilotingInterface.""" + return self.set_stream_status(1) + + def stop_stream(self) -> bool: + """Implement stop_stream from PilotingInterface.""" + return self.set_stream_status(0) + + def get_stream_rtmp_infos(self) -> dict: + """Implement get_stream_rtmp_infos from PilotingInterface.""" + json_conf = self.broadcaster.piloting_conf + conf = json.loads(json_conf) + + response = requests.get( + url=f"{self.url}?uri=/streamer/rtmp/{conf['rtmp_streamer_id']}", + headers={"Accept": "application/json", "Content-Type": "application/json"}, + auth=(conf["user"], conf["password"]), + ) + + if ( + response.status_code != http.HTTPStatus.OK + or not response.json() + or not type(response.json()) is list + ): + return {"error": "fail to fetch infos rtmp"} + + # Verify all infos are present in the streamer + streamer = response.json()[0] + if ( + streamer.get("result", "") + and streamer["result"].get("pub_url", "") + and "pub_control" in streamer["result"] + and "pub_while_record" in streamer["result"] + and streamer.get("meta", "") + and streamer["meta"].get("uri", "") + ): + return { + "streamer_id": int(conf["rtmp_streamer_id"]), + "auto_start_on_record": bool(streamer["result"]["pub_while_record"]), + "is_streaming": bool(streamer["result"]["pub_control"]), + } + return {} + + def set_stream_status(self, value) -> bool: + """Set the RTMP stream status and return if successfully done.""" + if not self.can_manage_stream: + return False + + json_conf = self.broadcaster.piloting_conf + conf = json.loads(json_conf) + response = requests.put( + self.url, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + auth=(conf["user"], conf["password"]), + data=json.dumps( + [ + { + "uri": f"/streamer/rtmp/{conf['rtmp_streamer_id']}/pub_control", + "value": value, + } + ] + ), + ) + return self.verify_smp_response(response, "result", value) + + @staticmethod + def verify_smp_response(response: requests.Response, key, value) -> bool: + """Verify SMP response is Ok and has key and value in it.""" + if response.status_code != http.HTTPStatus.OK: + return False + if not response.json(): + return False + + for resp in response.json(): + if resp.get(key, "") == value: + return True + for body in resp.values(): + if type(body) is dict and body.get(key, "") == value: + return True return False diff --git a/pod/live/static/css/event.css b/pod/live/static/css/event.css index 331566be25..16bfe0589e 100644 --- a/pod/live/static/css/event.css +++ b/pod/live/static/css/event.css @@ -6,4 +6,8 @@ } .video-js .vjs-remaining-time { display: none; -} \ No newline at end of file +} + .filter-event-img { + max-height:32px; + max-width:32px; +} diff --git a/pod/live/static/css/event_list.css b/pod/live/static/css/event_list.css new file mode 100644 index 0000000000..d8b38cec81 --- /dev/null +++ b/pod/live/static/css/event_list.css @@ -0,0 +1,4 @@ +.event-card-container { + min-width: 12rem; + min-height: 11rem; +} diff --git a/pod/live/static/js/admin_broadcaster.js b/pod/live/static/js/admin_broadcaster.js new file mode 100644 index 0000000000..4a93ba2486 --- /dev/null +++ b/pod/live/static/js/admin_broadcaster.js @@ -0,0 +1,44 @@ +document.addEventListener("DOMContentLoaded", function () { + const implementation_select = document.getElementById( + "id_piloting_implementation", + ); + const implementation_config = document.getElementById("id_piloting_conf"); + + if (implementation_select && implementation_config) { + changeImplPlaceholder(); + implementation_select.addEventListener("change", changeImplPlaceholder); + } + + /** + * Change the placeholder in the broadcaster admin view. + * + * When selecting a 'piloting_implementation' it fetches and display the mandatory parameters as a placeholder in the piloting_conf field. + */ + function changeImplPlaceholder() { + const selected_impl = implementation_select.value; + + if (selected_impl === "") return; + + // find mandatory param + let url = implementation_config.dataset.url + selected_impl; + fetch(url, { + method: "GET", + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }) + .then((response) => { + if (response.ok) return response.json(); + else return Promise.reject(response); + }) + .then((r) => { + implementation_config.setAttribute( + "placeholder", + JSON.stringify(r, null, 4), + ); + }) + .catch(() => { + alert(gettext("An error occurred")); + }); + } +}); diff --git a/pod/live/static/js/broadcaster_from_building.js b/pod/live/static/js/broadcaster_from_building.js index fb344335c4..39faf31537 100644 --- a/pod/live/static/js/broadcaster_from_building.js +++ b/pod/live/static/js/broadcaster_from_building.js @@ -3,7 +3,7 @@ document.addEventListener("DOMContentLoaded", function () { let restrictedCheckBox = document.getElementById("event_is_restricted"); let restrictedHelp = document.getElementById("event_is_restrictedHelp"); let restrictedLabel = document.getElementsByClassName( - "field_is_restricted" + "field_is_restricted", )[0]; let change_restriction = (restrict) => { @@ -11,13 +11,13 @@ document.addEventListener("DOMContentLoaded", function () { restrictedCheckBox.checked = true; restrictedCheckBox.setAttribute("onclick", "return false"); restrictedHelp.innerHTML = gettext( - "Restricted because the broadcaster is restricted" + "Restricted because the broadcaster is restricted", ); restrictedLabel.style.opacity = "0.5"; } else { restrictedCheckBox.removeAttribute("onclick"); restrictedHelp.innerHTML = gettext( - "If this box is checked, the event will only be accessible to authenticated users." + "If this box is checked, the event will only be accessible to authenticated users.", ); restrictedLabel.style.opacity = ""; } diff --git a/pod/live/static/js/filter_aside_event_list.js b/pod/live/static/js/filter_aside_event_list.js new file mode 100644 index 0000000000..0cdb07eb3a --- /dev/null +++ b/pod/live/static/js/filter_aside_event_list.js @@ -0,0 +1,123 @@ +let loader = document.querySelector(".lds-ring"); +let checkedInputs = []; + +/** + * Enable /disable all checkboxes. + * + * @param {boolean} value + */ +function disableCheckboxes(value) { + document.querySelectorAll("input[type=checkbox]").forEach((checkbox) => { + checkbox.disabled = value; + }); +} + +/** + * Return url with filters params. + * + * @returns {string} + */ +function getUrlForRefresh() { + let newUrl = window.location.pathname + "?"; + + checkedInputs.forEach((input) => { + newUrl += input.name + "=" + input.value + "&"; + }); + + // Add page parameter + newUrl += "page=1"; + return newUrl; +} + +/** + * Remove loader height. + * + * @param height + * @returns {number} + */ +function getHeightMinusLoader(height) { + let loader_style = getComputedStyle(loader); + let loader_height = loader_style.height; + loader_height = loader_height.replace("px", ""); + return height - loader_height; +} + +/** + * Async request to refresh view with filtered events list. + */ +function refreshEvents() { + // Erase list and enable loader + const events_content = document.getElementById("events_content"); + let width = events_content.offsetWidth; + let height = getHeightMinusLoader(events_content.offsetHeight); + + events_content.innerHTML = + "
"; + loader.classList.add("show"); + + let url = getUrlForRefresh(); + + // Async GET request wth parameters by fetch method + 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 replace videos list + let parser = new DOMParser(); + let html = parser.parseFromString(data, "text/html").body; + events_content.outerHTML = html.innerHTML; + + // change url with params sent + window.history.pushState({}, "", url); + }) + .catch(() => { + events_content.innerHTML = gettext("An Error occurred while processing."); + }) + .finally(() => { + // Re-enable inputs and dismiss loader + disableCheckboxes(false); + loader.classList.remove("show"); + }); +} + +/** + * Check or uncheck checkbox regarding url params. + * + * @param el + */ +function setCheckboxStatus(el) { + let currentUrl = window.location.href; + el.checked = currentUrl.includes("type=" + el.value + "&"); +} + +/** + * Add listener to refresh events list on checkbox status change. + * + * @param el + */ +function addCheckboxListener(el) { + el.addEventListener("change", () => { + checkedInputs = []; + disableCheckboxes(true); + document + .querySelectorAll("#collapseFilterType input[type=checkbox]:checked") + .forEach((e) => { + checkedInputs.push(e); + }); + refreshEvents(); + }); +} + +// On page load +document + .querySelectorAll("#collapseFilterType input[type=checkbox]") + .forEach((el) => { + setCheckboxStatus(el); + addCheckboxListener(el); + }); diff --git a/pod/live/static/js/viewcounter.js b/pod/live/static/js/viewcounter.js index a369a9b7d3..a38d9aaa42 100644 --- a/pod/live/static/js/viewcounter.js +++ b/pod/live/static/js/viewcounter.js @@ -89,10 +89,10 @@ document.addEventListener("DOMContentLoaded", function () { const MenuButton = videojs.getComponent("Button"); - const ViewerCountMenuButton = videojs.extend(MenuButton, { - constructor: function (player, options) { + class ViewerCountMenuButton extends MenuButton { + constructor(player, options) { options.label = "Viewers"; - MenuButton.call(this, player, options); + super(player, options); this.el().setAttribute("aria-label", "Viewers"); // videojs.dom.addClass(this.el(), "vjs-info-button"); this.controlText("Viewers"); @@ -102,8 +102,8 @@ document.addEventListener("DOMContentLoaded", function () { '' + eyeSVG + '?

'; - }, - }); + } + } ViewerCountMenuButton.prototype.handleClick = function (event) { MenuButton.prototype.handleClick.call(this, event); @@ -127,7 +127,7 @@ document.addEventListener("DOMContentLoaded", function () { MenuButton.registerComponent( "ViewerCountMenuButton", - ViewerCountMenuButton + ViewerCountMenuButton, ); // Initialize the plugin @@ -138,7 +138,7 @@ document.addEventListener("DOMContentLoaded", function () { if (settings.ui) { const menuButton = new ViewerCountMenuButton(player, settings); player.controlBar.info = player.controlBar.el_.appendChild( - menuButton.el_ + menuButton.el_, ); player.controlBar.info.dispose = function () { this.parentNode.removeChild(this); diff --git a/pod/live/templates/live/direct.html b/pod/live/templates/live/direct.html index 6f7228f493..e0ccfdb0d3 100644 --- a/pod/live/templates/live/direct.html +++ b/pod/live/templates/live/direct.html @@ -33,7 +33,7 @@

- + {% trans "Plan an event" %}

@@ -65,7 +65,7 @@

- {% blocktrans %}Recording in progress{% endblocktrans %} + {% blocktrans %}Recording in progress{% endblocktrans %}

{% else %} @@ -93,12 +93,12 @@

{% trans "Next events" %}

{% if request.user.is_superuser %}

- {% trans "Manage broadcaster" %} + {% trans "Manage broadcaster" %}

@@ -106,7 +106,7 @@

- + {{ broadcaster.building.name }} @@ -127,12 +127,12 @@

{% if otherbroadcaster.status %} - + {{ otherbroadcaster.name }} {% else %} - + {{ otherbroadcaster.name }} ({% trans "no broadcast in progress" %}) {% endif %} @@ -146,7 +146,7 @@

- + {% trans "access map" %} @@ -226,7 +226,11 @@

{% if broadcaster.enable_viewer_count%} player.videoJsViewerCount(); {% endif %} - player.videoJsLogo({imgsrc: '{% static LOGO_PLAYER %}', linktitle:'{{TITLE_ETB}} - {{TITLE_SITE}}', link:'{{LINK_PLAYER}}'}); + player.videoJsLogo({ + imgsrc: '{% static LOGO_PLAYER %}', + linktitle: '{{ TITLE_SITE }} - {% if LINK_PLAYER_NAME %}{{ LINK_PLAYER_NAME }}{% else %}{% trans "Home" %}{% endif %} - {% trans "New window" %}', + link: '{{ LINK_PLAYER }}' + }); player.on('error', function() { // Handle successives errors to avoid multiple reload if (typeof(errored) == 'undefined' || !errored) { diff --git a/pod/live/templates/live/directs.html b/pod/live/templates/live/directs.html index 177f0b4de8..fb30bdb0a6 100644 --- a/pod/live/templates/live/directs.html +++ b/pod/live/templates/live/directs.html @@ -15,7 +15,7 @@ {% block page_title %}{{building.name}}{% endblock %} {% block page_content %} -

 {{building.name}}

+

 {{building.name}}

{% if building.headband %} {{building.name}} {% endif %} diff --git a/pod/live/templates/live/directs_all.html b/pod/live/templates/live/directs_all.html index a0acc49485..5fe9a7ec26 100644 --- a/pod/live/templates/live/directs_all.html +++ b/pod/live/templates/live/directs_all.html @@ -17,7 +17,7 @@ {% block page_title %}{% trans "Lives" %}{% endblock %} {% block page_content %} -

 {% trans "Lives" %}

+

 {% trans "Lives" %}

{% for building in buildings %} @@ -29,20 +29,20 @@
{{building.name}} - +
{% for broadcaster in building.broadcaster_set.all %}

- {% if broadcaster.status %} {{broadcaster.name}} - {% else %} {{broadcaster.name}} ({% trans "no broadcast in progress" %}){% endif %} + {% if broadcaster.status %} {{broadcaster.name}} + {% else %} {{broadcaster.name}} ({% trans "no broadcast in progress" %}){% endif %}

{% empty %}

{% trans "Sorry, no lives found." %}

{% endfor %}
{% if building.gmapurl %} -

+

{% trans "access map" %}

{% endif %} diff --git a/pod/live/templates/live/event-all-info.html b/pod/live/templates/live/event-all-info.html index 6973154bac..05d2d4a499 100644 --- a/pod/live/templates/live/event-all-info.html +++ b/pod/live/templates/live/event-all-info.html @@ -1,27 +1,18 @@ {% load i18n %} {% load tagging_tags %} -

- {{event.title|capfirst}} - {% if event.start_date %}[{{ event.start_date }}]{% endif %} - - - -

@@ -43,14 +52,14 @@

-
+ - +
diff --git a/pod/main/templates/base.html b/pod/main/templates/base.html index 4f27f35602..ba8912352f 100644 --- a/pod/main/templates/base.html +++ b/pod/main/templates/base.html @@ -1,4 +1,6 @@ {% load static i18n custom_tags %} +{% load pwa %} +{% load webpush_notifications %} {% get_current_language as LANGUAGE_CODE %} @@ -40,15 +42,15 @@ {% block more_style %} {% endblock more_style %} - {{ TITLE_SITE }} - {% block page_title %}{{page_title|striptags|capfirst}}{% endblock %} - + {{ TITLE_SITE }} - {% block page_title %}{{page_title|striptags|capfirst}}{% endblock %} {% block page_extra_head %} {% endblock %} {% if request.GET.is_iframe %} {% endif %} + {% progressive_web_app_meta %} {% endspaceless %} - + {% webpush_header %} @@ -65,15 +67,20 @@ {% get_maintenance_welcome as maintenance_text %}
{{maintenance_text}}
{% endif %} -
+
{% if not request.GET.is_iframe %}
{% block collapse_page_aside %} - {% endblock collapse_page_aside %}
@@ -83,28 +90,37 @@ {% endif %} - -
- {% block main_page_title %} - {% if page_title %} -

{{page_title|capfirst}}

+
+
+ {% block main_page_title %} + {% if page_title %} +

{{page_title|capfirst}}

+ {% endif %} + {% endblock main_page_title %} + {% block page_content %}{% endblock page_content %} +
+ {% if request.path == "/" %} + {% if SHOW_EVENTS_ON_HOMEPAGE and "live" in THIRD_PARTY_APPS %} + {% include "live/events_next.html" %} {% endif %} - {% endblock main_page_title %} - {% block page_content %} - {% endblock page_content %} -
- - {% if request.path == "/" %} - {% if SHOW_EVENTS_ON_HOMEPAGE and "live" in THIRD_PARTY_APPS %} - {% include "live/events_next.html" %} + {% include "videos/last_videos.html" %} {% endif %} - {% include "videos/last_videos.html" %} - {% endif %} +
{% if not request.GET.is_iframe %}
+ {% include "notification_toast.html" %}
{% endblock content %} {% if not request.GET.is_iframe %} @@ -163,18 +180,34 @@

{{page_title|capfirst}}

+ {% if HIDE_CHANNEL_TAB == False %} + + + {% endif %} + +
+ {% block more_script %} + {% endblock more_script %} +
{% if DARKMODE_ENABLED == True %} {% endif %} - {% if DARKMODE_ENABLED == True or DYSLEXIAMODE_ENABLED == True %} - - {% endif %} + + {% if POST_FOOTER_TEMPLATE %}{% include POST_FOOTER_TEMPLATE %}{% endif %} {% if TRACKING_TEMPLATE %}{% include TRACKING_TEMPLATE %}{% endif %} + diff --git a/pod/main/templates/footer.html b/pod/main/templates/footer.html index 747017db2f..658e92e50d 100644 --- a/pod/main/templates/footer.html +++ b/pod/main/templates/footer.html @@ -3,34 +3,40 @@ {% spaceless %}

- - + +

-