From 26af203778c7d1c6495febcafeeee563da42cca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Coze?= <96086580+SebastienCozeDev@users.noreply.github.com> Date: Tue, 4 Jun 2024 15:34:45 +0200 Subject: [PATCH] [DONE] Add the AI Enhancement application (#1025) * Create the AI Enhancement application * Add the connection to the API * Create the function to connect to the API * Create the function to get the enrichment * Create the function to get a special enrichment * Add some tests * Add unit tests & view * Fix for Flake8 * Create the AIEnrichment models & test it * Add the USE_AI_ENHANCEMENT setting * Create the function to create an enrichmenet from an URL * Create the method to get the latest version of enrichment * Create the method to get the versions of an enrichment * Create the method to get a specific version of an enrichment * Update the unit tests * Create the AI_ENRICHMENT_CLIENT_ID & AI_ENRICHMENT_SECRET settings * Add the AI button * Begin to add the AI enhancement creation * Add the init file for the migraton * Add the enrichment creation with the button * Edit the video view to use the AI if the user ask it * Create the title and description field for the choice form * Fix the admin, model & template tags * Create the view to choose AI generated or human created field for the video informations * Fix flake8 * Renomme template * Rename variable to snake case * Create the view to choose AI generated or humain created field for the transcription * Change console.error for showalert * Add the view to toggle webhook * Add redirection when the user create an ai enhancement * Add the extract_json_from_str util function * Fix the decode bug in the choice form * Fix the discipline field in the choice form * Fix flake8 * Add unit tests for the extract_json_from_str function * Put the CSS code in css file * Add the AI Enhancement app in configuration.json * Update the subtitles choice with IA * Fix flake8 * Fix the empty discipline bug * Add pydoc * Realize the TODO * Add the deletion of the enrichment at the end * Fix some problem * Add the translations * Fix flake8 * Remove the AI_ENRICHMENT_DIR setting * Add the docs for the settings * Add a line at the end of the file * Fix flake8 * Add JS Doc & remove enrich-transcript.js file * Add & rename some settings & add the settings in the file test * Update the description * Remove print * Change 'enrichment' by 'enhancement' * Fix template tags * Update forms.py * Remove unused aria-label & some blank lines * Add global docs * Move some util functions to the main package * Fix the tilte page * Change 'warning' to 'success' * Translate * Change 'Aristotle' to 'Aristote' * Add the notify for thrid-party service * Fix the link * Fix flake8 * Make the translations * Change the default value for USE_AI_ENHANCEMENT * Rename user_can_enrich_video by user_can_enhance_video * Rename enrich_video & enrich_video_json by enhance_video & enhance_video_json * Rename enrich_subtitles by enhance_subtitles * Rename enrich_form by enhance_form * Rename settings * Update test settings * Restrict AI enhancement to the staff only * Remove title * Remove aria-label * Update the create_enhancement template * Update views * Remove console.log * Update the configuration file * Update the documentation * Update str in forms * Make enhancements available for staff only * Add help texts in the AIEnhancement model * Add version * Use bootstrap * Replace str by slug * Change 'my' bu 'the' * data-bs-toggle & data-bs-placement * Remove convert_time & pad_zero function * Move the stylesheet link in the page_extra_head block * Make lang * Update translations * Compile lang * Fix some bugs * Simplify the video view * Add some imports * :bug: Fix the create_enhancement_from_url method * Handle the errors * :white_check_mark: Add unit tests for the get_token function * Add __str__ method & sites property * Make translations * Fix flake8 * :white_check_mark: Add unit tests for the model * :white_check_mark: Add unit tests for the enhance video route * :shirt: Remove unnecessary imports * Make the Badatos requested changes * Add filter dans search fields for AIEnhancement * Change ForeignKey to OneToOneField * Add AI_ENHANCEMENT_FIELDS_HELP_TEXT setting * remove protocole to fetch, maybe get url in ai-enhancement/enrich-video template * mark False as default value tu USE_AI_ENHANCEMENT * add notify user at the end of IA improvement * add translations * add restrict access to ai enhancement - remove delete ai enhancement * add signal to delete enhancement - use update or create to create one - remove delete calling from video application and remove tag to show tooltips * add link to go to subtitle part and return to video - fix link to fetch * add translation * fix translation and unit test * fix unit test and check if ai enhancement exist before deletion * remove quote from ai title and use client get uel in test view * add login in test views * fix translation * Change MP4 format to MP3 format for Aristote * :bug: Fix the media_types tab * refactor get video mp3 url * add link to get more information about third service * Feat implement quiz in Aristote enhancement * add AI_ENHANCEMENT_CGU_URL in configuration * :bug: Fix the keywords field * Update JSDoc * Fix review requests * Fix missing function error * Fix translations * :children_crossing: Remove tag when it is in input tag in Aristote form * :children_crossing: Modify video position in quiz page * :children_crossing: Improve quiz access in video page * :children_crossing: Add scroll when click on collapse button * use hashlib to pseudonymise user with AI * :lipstick: Improve ui of some quiz pages * :sparkles: Add the button in the Aristote page to delete the enhancement * :art: Improve css quiz code * :bug: Fix an error when submitting an empty quiz * :globe_with_meridians: Add translations to delete the enhancement * delete all instance of enhancement when video encoding * :technologist: Add filter to get a value in dict * :memo: Update PyDoc * :memo: Update version in doc * :lipstick: Add border color when submitting quiz * :recycle: Create function to select an element * :lipstick: Improve question infos when submitting * :children_crossing: Improve buttons in quiz page * :art: Move decodeString & removeAccentsAndLowerCase function in main.js * :technologist: Remove log * :lipstick: Replace success by primary * :globe_with_meridians: Change IA to AI * :zap: Add penalty when select incorrect anwer in multiple choice question * :memo: Add commentary * :globe_with_meridians: Update the french translation * :loud_sound: Add logs * :rotating_light: Apply Flake8 & black * :ambulance: Fix bug that prevented deleting a question in a quiz * :children_crossing: Add delete quiz button in aside menu in video page * :recycle: Simplify update_questions function * :children_crossing: Add d-none class for the deleteIpnuts * :fire: Remove unnecessary code * fix btn secondary and translation * :bug: Fix non-creation question * add new line at the end of file * move import quizz to utils and add redirect to quiz edit * add question id in quizz form to update or create question * replace go to by import * fix dlete and create question for quizz * fix add quizz * put the default value of create quiz to 1 and fix bug when deleting new form * play video when show reponse quiz * keep user answer when submit quizz, show question form error, add play for video player * fix extra formset to 1 * fix translation and add pydoc and improve get question to prevent exception --------- Co-authored-by: ptitloup Co-authored-by: Aymeric Jakobowski --- pod/ai_enhancement/__init__.py | 0 pod/ai_enhancement/admin.py | 27 + pod/ai_enhancement/apps.py | 11 + pod/ai_enhancement/context_processors.py | 16 + pod/ai_enhancement/forms.py | 264 ++++++ pod/ai_enhancement/migrations/__init__.py | 0 pod/ai_enhancement/models.py | 65 ++ .../css/choose-video-element.css | 8 + .../static/ai_enhancement/js/enrich-form.js | 237 ++++++ .../templates/choose_video_element.html | 125 +++ .../templates/create_enhancement.html | 66 ++ .../templates/delete_enhancement.html | 66 ++ pod/ai_enhancement/templatetags/__init__.py | 0 .../ai_enhancement_template_tags.py | 82 ++ pod/ai_enhancement/tests/__init__.py | 0 pod/ai_enhancement/tests/test_models.py | 46 + pod/ai_enhancement/tests/test_utils.py | 336 ++++++++ pod/ai_enhancement/tests/test_views.py | 282 +++++++ pod/ai_enhancement/urls.py | 30 + pod/ai_enhancement/utils.py | 366 ++++++++ pod/ai_enhancement/views.py | 476 +++++++++++ pod/completion/static/js/caption_maker.js | 26 +- .../templates/video_caption_maker.html | 12 +- pod/locale/fr/LC_MESSAGES/django.mo | Bin 212020 -> 220028 bytes pod/locale/fr/LC_MESSAGES/django.po | 787 +++++++++++++----- pod/locale/fr/LC_MESSAGES/djangojs.mo | Bin 21118 -> 21460 bytes pod/locale/fr/LC_MESSAGES/djangojs.po | 40 +- pod/locale/nl/LC_MESSAGES/django.mo | Bin 1923 -> 2059 bytes pod/locale/nl/LC_MESSAGES/django.po | 739 +++++++++++----- pod/locale/nl/LC_MESSAGES/djangojs.po | 18 +- pod/main/configuration.json | 120 +++ pod/main/static/js/main.js | 49 ++ pod/main/templatetags/filters.py | 8 + pod/main/test_settings.py | 7 + pod/main/utils.py | 29 + pod/playlist/templates/playlist/delete.html | 2 +- pod/quiz/forms.py | 18 +- pod/quiz/static/quiz/js/create-quiz.js | 27 +- pod/quiz/static/quiz/js/video-quiz-submit.js | 32 +- pod/quiz/templates/quiz/create_edit_quiz.html | 14 +- pod/quiz/templates/quiz/delete_quiz.html | 2 +- pod/quiz/templates/quiz/question_form.html | 4 +- pod/quiz/templates/quiz/video_quiz.html | 125 +-- pod/quiz/templates/quiz/video_quiz_aside.html | 36 + pod/quiz/templatetags/video_quiz.py | 22 + pod/quiz/tests/test_views.py | 2 +- pod/quiz/utils.py | 53 ++ pod/quiz/views.py | 280 +++++-- pod/settings.py | 2 + pod/urls.py | 7 + pod/video/templates/videos/link_video.html | 13 +- pod/video/templates/videos/video-info.html | 7 - pod/video/templates/videos/video_aside.html | 32 +- pod/video/templates/videos/video_edit.html | 4 +- .../templates/videos/video_page_content.html | 10 + pod/video_encode_transcript/transcript.py | 8 +- 56 files changed, 4410 insertions(+), 628 deletions(-) create mode 100644 pod/ai_enhancement/__init__.py create mode 100644 pod/ai_enhancement/admin.py create mode 100644 pod/ai_enhancement/apps.py create mode 100644 pod/ai_enhancement/context_processors.py create mode 100644 pod/ai_enhancement/forms.py create mode 100644 pod/ai_enhancement/migrations/__init__.py create mode 100644 pod/ai_enhancement/models.py create mode 100644 pod/ai_enhancement/static/ai_enhancement/css/choose-video-element.css create mode 100644 pod/ai_enhancement/static/ai_enhancement/js/enrich-form.js create mode 100644 pod/ai_enhancement/templates/choose_video_element.html create mode 100644 pod/ai_enhancement/templates/create_enhancement.html create mode 100644 pod/ai_enhancement/templates/delete_enhancement.html create mode 100644 pod/ai_enhancement/templatetags/__init__.py create mode 100644 pod/ai_enhancement/templatetags/ai_enhancement_template_tags.py create mode 100644 pod/ai_enhancement/tests/__init__.py create mode 100644 pod/ai_enhancement/tests/test_models.py create mode 100644 pod/ai_enhancement/tests/test_utils.py create mode 100644 pod/ai_enhancement/tests/test_views.py create mode 100644 pod/ai_enhancement/urls.py create mode 100644 pod/ai_enhancement/utils.py create mode 100644 pod/ai_enhancement/views.py create mode 100644 pod/quiz/templates/quiz/video_quiz_aside.html diff --git a/pod/ai_enhancement/__init__.py b/pod/ai_enhancement/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pod/ai_enhancement/admin.py b/pod/ai_enhancement/admin.py new file mode 100644 index 0000000000..1f74d98970 --- /dev/null +++ b/pod/ai_enhancement/admin.py @@ -0,0 +1,27 @@ +"""Esup-Pod AI enhancement admin.""" +from django.contrib import admin + +from .models import AIEnhancement + + +@admin.register(AIEnhancement) +class AIEnhancementAdmin(admin.ModelAdmin): + """AIEnhancement admin page.""" + + date_hierarchy = "updated_at" + list_display = ( + "id", + "ai_enhancement_id_in_aristote", + "video", + "is_ready", + "created_at", + "updated_at", + ) + list_display_links = ("id", "ai_enhancement_id_in_aristote") + list_filter = ("is_ready", "created_at", "updated_at") + search_fields = [ + "ai_enhancement_id_in_aristote", + "video__title", + "video__id", + "video__slug", + ] diff --git a/pod/ai_enhancement/apps.py b/pod/ai_enhancement/apps.py new file mode 100644 index 0000000000..8070d36336 --- /dev/null +++ b/pod/ai_enhancement/apps.py @@ -0,0 +1,11 @@ +"""Esup-Pod AI Enhancement apps.""" +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class IaEnhancementConfig(AppConfig): + """AI Enhancement app configuration.""" + + name = "pod.ai_enhancement" + default_auto_field = "django.db.models.BigAutoField" + verbose_name = _("Artificial Intelligence Enhancement") diff --git a/pod/ai_enhancement/context_processors.py b/pod/ai_enhancement/context_processors.py new file mode 100644 index 0000000000..da3f2ed665 --- /dev/null +++ b/pod/ai_enhancement/context_processors.py @@ -0,0 +1,16 @@ +"""Esup-Pod ai_enhancement context_processors.""" +from django.conf import settings as django_settings + +USE_AI_ENHANCEMENT = getattr( + django_settings, + "USE_AI_ENHANCEMENT", + False, +) + + +def context_settings(request): + """Return all context settings for ai_enhancement app""" + new_settings = { + "USE_AI_ENHANCEMENT": USE_AI_ENHANCEMENT, + } + return new_settings diff --git a/pod/ai_enhancement/forms.py b/pod/ai_enhancement/forms.py new file mode 100644 index 0000000000..f5b0e99aac --- /dev/null +++ b/pod/ai_enhancement/forms.py @@ -0,0 +1,264 @@ +"""Forms used in ai_enhancement application.""" +from django import forms +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from tagging.fields import TagField + +from pod.main.forms_utils import add_placeholder_and_asterisk +from pod.video.models import Video, Discipline + +AI_ENHANCEMENT_FIELDS_HELP_TEXT = getattr( + settings, + "AI_ENHANCEMENT_FIELDS_HELP_TEXT", + { + "title": { + "choose_information_string": _("Choose the title."), + "initial_information": _("The initial title is loading…"), + "initial_information_t": _("Your initial title."), + "ai_information": _("The title proposed by the Aristote AI is loading…"), + "ai_information_t": _("The title proposed by the Aristote AI."), + }, + "description": { + "choose_information_string": _("Choose the description."), + "initial_information": _("The initial description is loading…"), + "initial_information_t": _("Your initial description."), + "ai_information": _( + "The description proposed by the Aristote AI is loading…" + ), + "ai_information_t": _("The description proposed by the Aristote AI."), + }, + "tags": { + "choose_information_string": _("Choose the tags."), + "ai_information": _("The tags proposed by the Aristote AI is loading…"), + }, + "discipline": { + "choose_information_string": _("Choose the discipline."), + "initial_information": _("The initial discipline is loading…"), + "initial_information_t": _("Your initial discipline."), + "ai_information": _("The discipline proposed by the Aristote AI is loading…"), + "ai_information_t": _("The discipline proposed by the Aristote AI."), + }, + }, +) +AI_ENHANCEMENT_CGU_URL = getattr( + settings, + "AI_ENHANCEMENT_CGU_URL", + "", +) + + +class AIEnhancementChoice(forms.ModelForm): + """Form class for choosing the title of a video with the AI enhancement.""" + + class Meta: + """Meta class.""" + + model = Video + fields = [ + "title", + "description", + "tags", + "discipline", + ] + + title = forms.CharField( + label=_("Title"), + widget=forms.TextInput( + attrs={ + "aria-describedby": "id_titleHelp", + }, + ), + help_text=_( + """ + Please choose a title between 1 and 250 characters. + """ + ), + ) + + description = forms.CharField( + label=_("Description"), + widget=forms.Textarea( + attrs={ + "aria-describedby": "id_descriptionHelp", + }, + ), + required=False, + help_text=_("Please choose a description."), + ) + + tags = TagField( + help_text=_( + """ + Please choose tags for your video. + Separate tags with spaces, enclose the tags consist of several words in quotation marks. + """ + ), + verbose_name=_("Tags"), + ) + + disciplines = forms.ModelChoiceField( + label=_("Discipline"), + queryset=Discipline.objects.all(), + required=False, + help_text=_("Please choose the discipline of your video."), + ) + + fieldsets = [ + ( + "choose_title", + { + "legend": f" \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['title']['choose_information_string']}
\ +
\ +
\ +
\ + \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['title']['initial_information']}\ +
\ +
\ +
\ + \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['title']['ai_information']}\ +
\ +
\ + \ + ", + "fields": ["title"], + }, + ), + ( + "choose_description", + { + "legend": f" \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['description']['choose_information_string']}
\ +
\ +
\ +
\ + \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['description']['initial_information']}\ +
\ +
\ +
\ + \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['description']['ai_information']}\ +
\ +
\ + \ + ", + "fields": ["description"], + }, + ), + ( + "choose_tags", + { + "legend": f" \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['tags']['choose_information_string']}
\ +
\ +
\ +
\ + \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['tags']['ai_information']}\ +
\ +
\ +
\ + ", + "fields": ["tags"], + }, + ), + ( + "choose_disciplines", + { + "legend": f" \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['discipline']['choose_information_string']}
\ +
\ +
\ +
\ + \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['discipline']['initial_information']}\ +
\ +
\ +
\ + \ + {AI_ENHANCEMENT_FIELDS_HELP_TEXT['discipline']['ai_information']}\ +
\ +
\ + \ + ", + "fields": ["disciplines"], + }, + ), + ] + + def __init__(self, *args, **kwargs): + """Init method.""" + super(AIEnhancementChoice, self).__init__(*args, **kwargs) + self.fields = add_placeholder_and_asterisk(self.fields) + + +class NotifyUserThirdPartyServicesForm(forms.Form): + """Form to notify user about third party services.""" + + agree = forms.BooleanField( + label=_("I agree to use third-party services"), + help_text=_( + "Please check this box if you agree to use a third-party service to improve this video." + ), + widget=forms.CheckboxInput( + attrs={ + "aria-describedby": "id_agreeHelp", + }, + ), + ) + + def __init__(self, *args, **kwargs) -> None: + """Init method.""" + super(NotifyUserThirdPartyServicesForm, self).__init__(*args, **kwargs) + self.fields = add_placeholder_and_asterisk(self.fields) + if AI_ENHANCEMENT_CGU_URL != "" and self.fields.get("agree"): + to_know_more = '' % AI_ENHANCEMENT_CGU_URL + to_know_more += ( + '  ' + ) + to_know_more += _("For more information.") + to_know_more += "" + self.fields["agree"].help_text += " " + to_know_more + + +class NotifyUserDeleteEnhancementForm(forms.Form): + """Form to notify user before delete an enhancement.""" + + confirm = forms.BooleanField( + label=_("I want to delete this enhancement"), + help_text=_("Please check this box if you want to delete this enhance."), + widget=forms.CheckboxInput( + attrs={ + "aria-describedby": "id_confirmHelp", + }, + ), + ) + + def __init__(self, *args, **kwargs) -> None: + """Init method.""" + super(NotifyUserDeleteEnhancementForm, self).__init__(*args, **kwargs) + self.fields = add_placeholder_and_asterisk(self.fields) + if AI_ENHANCEMENT_CGU_URL != "" and self.fields.get("agree"): + to_know_more = '' % AI_ENHANCEMENT_CGU_URL + to_know_more += ( + '  ' + ) + to_know_more += _("For more information.") + to_know_more += "" + self.fields["agree"].help_text += " " + to_know_more diff --git a/pod/ai_enhancement/migrations/__init__.py b/pod/ai_enhancement/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pod/ai_enhancement/models.py b/pod/ai_enhancement/models.py new file mode 100644 index 0000000000..01f15bce75 --- /dev/null +++ b/pod/ai_enhancement/models.py @@ -0,0 +1,65 @@ +from django.db import models +from django.utils.translation import ugettext as _ +from django.dispatch import receiver +from django.db.models.signals import post_save +from pod.video.models import Video + + +class AIEnhancement(models.Model): + """AIEnhancement model.""" + + class Meta: + """Metadata class for AIEnhancement model.""" + + ordering = ["-created_at"] + get_latest_by = "updated_at" + verbose_name = _("AI enhancement") + verbose_name_plural = _("AI enhancements") + + video = models.OneToOneField( + Video, + verbose_name=_("Video"), + on_delete=models.CASCADE, + help_text=_("Select the video to enhance with AI"), + ) + created_at = models.DateTimeField( + verbose_name=_("Created at"), + auto_now_add=True, + help_text=_("The date and time when the enhancement was created"), + ) + updated_at = models.DateTimeField( + verbose_name=_("Updated at"), + auto_now=True, + help_text=_("The date and time when the enhancement was updated"), + ) + is_ready = models.BooleanField( + verbose_name=_("Is ready"), + default=False, + help_text=_("Check if the enhancement is ready"), + ) + ai_enhancement_id_in_aristote = models.TextField( + verbose_name=_("AI enhancement ID in Aristote"), + help_text=_("Enter the ID of the enhancement in Aristote"), + ) + + @property + def sites(self) -> models.QuerySet: + """Return the sites of the video.""" + return self.video.sites + + def __str__(self) -> str: + """Return the string representation of the AI enhancement.""" + return f"{self.video.title} - {self.ai_enhancement_id_in_aristote}" + + +@receiver(post_save, sender=Video) +def delete_AIEnhancement(sender, instance, created, **kwargs): + """ + Delete AIEnhancement if launch encoding if requested. + + Args: + sender (:class:`pod.video.models.Video`): Video model class. + instance (:class:`pod.video.models.Video`): Video object instance. + """ + if hasattr(instance, "launch_encode") and instance.launch_encode is True: + AIEnhancement.objects.filter(video=instance).delete() diff --git a/pod/ai_enhancement/static/ai_enhancement/css/choose-video-element.css b/pod/ai_enhancement/static/ai_enhancement/css/choose-video-element.css new file mode 100644 index 0000000000..f54e83bb65 --- /dev/null +++ b/pod/ai_enhancement/static/ai_enhancement/css/choose-video-element.css @@ -0,0 +1,8 @@ +.border-d { + border: 1px dashed; +} + +.border-d:hover, .enrich-input-selected { + border: 1px solid; + background-color: var(--background-color); +} diff --git a/pod/ai_enhancement/static/ai_enhancement/js/enrich-form.js b/pod/ai_enhancement/static/ai_enhancement/js/enrich-form.js new file mode 100644 index 0000000000..c43a253a55 --- /dev/null +++ b/pod/ai_enhancement/static/ai_enhancement/js/enrich-form.js @@ -0,0 +1,237 @@ +/** + * @file Esup-Pod functions for enrich-form. + * + * @since 3.7.0 + */ + + +const BORDER_CLASS = 'border-d'; + +const ENRICH_INPUT_SELECTED = 'enrich-input-selected'; + +const TAGS_PER_LINE = 4; + + +/** + * Select the element to select and unselect the element to unselect. + * + * @param {HTMLElement} elementToSelect The element to select. + * @param {HTMLElement} elementToUnselect The element to unselect. + */ +function selectElement(elementToSelect, elementToUnselect) { + elementToSelect.classList.remove(BORDER_CLASS); + elementToSelect.classList.add(ENRICH_INPUT_SELECTED); + elementToUnselect.classList.remove(ENRICH_INPUT_SELECTED); + elementToUnselect.classList.add(BORDER_CLASS); +} + + +/** + * Toggle the input and the two versions of the element. + * + * @param {HTMLElement} selectedElement The selected element. + * @param {HTMLElement} notSelectedElement The not selected element. + * @param {HTMLElement} input The input element. + * @param {string} elementName The name of the element. + */ +function togglePairInput(selectedElement, notSelectedElement, input, elementName) { + selectElement(selectedElement.children[0], notSelectedElement.children[0]); + let newString = selectedElement.children[0].textContent.trim(); + switch (elementName) { + case 'title': + input.value = newString; + break; + case 'description': + if (!selectedElement.children[0].classList.contains('no-content')) { + input.value = newString; + } else { + input.value = ''; + } + break; + } +} + + +/** + * Toggle the input and the multiple versions of the element. + * + * @param {HTMLElement} selectedElement The selected element. + * @param {HTMLElement} notSelectedElement The not selected element. + * @param {HTMLElement} input The input element. + */ +function toggleMultiplePairInput(selectedElement, notSelectedElement, input) { + selectElement(selectedElement.children[0], notSelectedElement.children[0]); + let newString = selectedElement.children[0].textContent.trim(); + if (newString === gettext('No discipline')) { + input.value = ''; + } else { + for (let i = 0; i < input.options.length; i++) { + if (input.options[i].text === newString) { + input.value = input.options[i].value; + break; + } + } + } +} + + +/** + * Add event listeners to the input and the two versions of the element. + * + * @param {HTMLElement} aiVersionElement The AI version of the element. + * @param {HTMLElement} initialVersionElement The initial version of the element. + * @param {HTMLElement} input The input element. + * @param {string} element The name of the element. + */ +function addTogglePairInput(aiVersionElement, initialVersionElement, input, element) { + togglePairInput(aiVersionElement, initialVersionElement, input, element); + initialVersionElement.addEventListener('click', () => { + let input = document.getElementById('id_' + element); + togglePairInput(initialVersionElement, aiVersionElement, input, element); + }); + aiVersionElement.addEventListener('click', () => { + let input = document.getElementById('id_' + element); + togglePairInput(aiVersionElement, initialVersionElement, input, element); + }) + input.addEventListener('input', () => event__inputChange(initialVersionElement, aiVersionElement)); +} + + +/** + * This function is called when the input is changed. + * + * @param {HTMLElement} initialVersionElement The initial version of the element. + * @param {HTMLElement} aiVersionElement The AI version of the element. + */ +function event__inputChange(initialVersionElement, aiVersionElement) { + initialVersionElement.children[0].classList.add(BORDER_CLASS); + initialVersionElement.children[0].classList.remove(ENRICH_INPUT_SELECTED); + aiVersionElement.children[0].classList.remove(ENRICH_INPUT_SELECTED); + aiVersionElement.children[0].classList.add(BORDER_CLASS); +} + + +/** + * Add the tags elements to the tags container. + * + * @param {string[]} tags The list of tags. + * @param {HTMLElement} input The input element. + */ +function addTagsElements(tags, input) { + const tagsContainerElement = document.getElementById('tags-container'); + let tagLineElement = tagsContainerElement.children[tagsContainerElement.children.length - 1]; + for (let i = 0; i < tags.length; i++) { + if (i % TAGS_PER_LINE === 0 && i !== 0) { + tagLineElement = document.createElement('div'); + tagLineElement.classList.add('row'); + tagsContainerElement.appendChild(tagLineElement); + } + const tagElement = document.createElement('div'); + tagElement.classList.add('col'); + const selectableElement = document.createElement('div'); + selectableElement.classList.add('border-d', 'rounded-4', 'p-3', 'mb-3', 'mt-3', 'blockquote'); + selectableElement.textContent = tags[i]; + tagElement.addEventListener('click', () => { + selectableElement.classList.add(ENRICH_INPUT_SELECTED); + if (input.value.length > 0) { + input.value += ` "${tags[i]}"`; + } else { + input.value = `"${tags[i]}"`; + } + tagElement.remove(); + }); + tagElement.appendChild(selectableElement); + tagLineElement.appendChild(tagElement); + } + const tagsInformationsElement = document.getElementById('tags-informations-text'); + if (tagsInformationsElement) { + tagsInformationsElement.remove(); + } +} + + +/** + * Set the information or an empty string in the element. + * + * @param {HTMLElement} element The element to set the information. + * @param {string} value The value to set in the element. + * @param {string} message The message to set in the element if the value is empty. + */ +function setInformationOrEmptyString(element, value, message) { + if (value.length > 0) { + element.children[0].innerHTML = decodeString(value); + } else { + element.children[0].textContent = gettext(message); + element.children[0].classList.add('text-muted', 'font-italic', 'no-content'); + } +} + + +/** + * Add event listeners to the elements. + * + * @param {string} videoSlug The video slug. + * @param {string} videoTitle The video title. + * @param {string} videoDescription The video description. + * @param {string} videoDiscipline The video discipline. + * @param {string} json_url The url to fetch to get json information. + */ +function addEventListeners(videoSlug, videoTitle, videoDescription, videoDiscipline, json_url) { + const elements = [ + 'title', + 'description', + 'tags', + 'disciplines', + ] + const options = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }; + fetch(json_url, options) + .then(response => response.json()) + .then(response => { + for (let element of elements) { + let initialVersionElement = document.getElementById('initial-version-' + element); + let aiVersionElement = document.getElementById('ai-version-' + element); + let input = document.getElementById('id_' + element); + switch (element) { + case 'title': + initialVersionElement.children[0].textContent = decodeString(videoTitle); + aiVersionElement.children[0].textContent = remove_quotes(response['enrichmentVersionMetadata']['title']); + addTogglePairInput(aiVersionElement, initialVersionElement, input, element); + break; + case 'description': + setInformationOrEmptyString(initialVersionElement, videoDescription, gettext('No description')); + aiVersionElement.children[0].textContent = response['enrichmentVersionMetadata']['description']; + addTogglePairInput(aiVersionElement, initialVersionElement, input, element); + break; + case 'tags': + const topics = response['enrichmentVersionMetadata']['topics']; + const newTopics = []; + const tagInput = document.getElementById('id_tags'); + for (let i = 0; i < topics.length; i++) { + if (!tagInput.value.includes(removeAccentsAndLowerCase(topics[i]))) { + newTopics.push(topics[i]); + } + } + addTagsElements(newTopics, input); + break; + case 'disciplines': + setInformationOrEmptyString(initialVersionElement, videoDiscipline, gettext('No discipline')); + aiVersionElement.children[0].textContent = response['enrichmentVersionMetadata']['discipline']; + toggleMultiplePairInput(aiVersionElement, initialVersionElement, input); + initialVersionElement.addEventListener('click', () => { + toggleMultiplePairInput(initialVersionElement, aiVersionElement, input); + }); + aiVersionElement.addEventListener('click', () => { + toggleMultiplePairInput(aiVersionElement, initialVersionElement, input); + }) + input.addEventListener('input', () => event__inputChange(initialVersionElement, aiVersionElement)); + break; + } + } + }) + .catch(err => showalert(gettext("An error has occurred. Please try later."), 'alert-danger')); +} diff --git a/pod/ai_enhancement/templates/choose_video_element.html b/pod/ai_enhancement/templates/choose_video_element.html new file mode 100644 index 0000000000..ae7e23f2b2 --- /dev/null +++ b/pod/ai_enhancement/templates/choose_video_element.html @@ -0,0 +1,125 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block page_extra_head %} + + +{% endblock page_extra_head %} + +{% block breadcrumbs %} + {{ block.super }} + + + +{% endblock breadcrumbs %} + +{% block main_page_title %} +{% endblock main_page_title %} + +{% block page_content %} + {{ page_title }} + + +  {% trans "Import subtitle" %} + + + + +  {% trans "Import the quiz" %} + + + + +  {% trans "Remove this enhancement" %} + + +
+ {% csrf_token %} + {% if form.errors %} +

+ {% trans "One or more errors have been found in the form." %}
+ {% for error in form.errors %} + {% if error != "__all__" %} + - {{ error }}
+ {% endif %} + {% endfor %} + {% for error in form.non_field_errors %} + - {{ error }}
+ {% endfor %} +

+ {% endif %} + + {% for field_hidden in form.hidden_fields %} + {{ field_hidden }} + {% endfor %} + {% for fieldset in form.fieldsets %} + {% with options=fieldset|last name=fieldset|first %} +
+ {% if options.legend %} + {{ options.legend|safe }} + {% endif %} + {% for field in form.visible_fields %} + {% if field.name in options.fields %} + {% spaceless %} +
+
+ {{ field.errors }} + {% if "form-check-input" in field.field.widget.attrs.class %} +
+ {{ field }} +
+ {% else %} + + {{ field }} + {% endif %} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} + {% if field.field.required %}
{% trans "Please provide a valid value for this field." %}
{% endif %} +
+
+ {% endspaceless %} + {% endif %} + {% endfor %} +
+ {{ options.additional_data|safe }} +
+
+ {% endwith %} + {% endfor %} +
+ + +  {% trans 'Back to the video' %} +
+
+{% endblock page_content %} + +{% block collapse_page_aside %}{% endblock collapse_page_aside %} + +{% block page_aside %}{% endblock page_aside %} + +{% block more_script %} + + {{ form.media }} + + +{% endblock more_script %} diff --git a/pod/ai_enhancement/templates/create_enhancement.html b/pod/ai_enhancement/templates/create_enhancement.html new file mode 100644 index 0000000000..fa149cf549 --- /dev/null +++ b/pod/ai_enhancement/templates/create_enhancement.html @@ -0,0 +1,66 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block breadcrumbs %} + {{ block.super }} + + +{% endblock breadcrumbs %} + +{% block page_content %} +

{% trans 'To enhance the video with Aristote AI, please check the box and click confirm.' %}

+
+ {% csrf_token %} +
+
+ {% trans 'Agreement required' %} + {% if form.errors %} +

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

+ {% endif %} + {% for field_hidden in form.hidden_fields %} + {{field_hidden}} + {% endfor %} + {% for field in form.visible_fields %} + {% spaceless %} +
+
+ {{ field.errors }} +
+ {{ field }} +
+ {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} + {% if field.field.required %} +
{% trans 'Please provide a valid value for this field.' %}
+ {% endif %} +
+
+ {% endspaceless %} + {% endfor %} +
+ +  {% trans 'Back to the video' %} + + +
+
+
+
+{% endblock page_content %} + +{% block collapse_page_aside %} +{% endblock collapse_page_aside %} + +{% block page_aside %} +{% endblock page_aside %} diff --git a/pod/ai_enhancement/templates/delete_enhancement.html b/pod/ai_enhancement/templates/delete_enhancement.html new file mode 100644 index 0000000000..2c898d137a --- /dev/null +++ b/pod/ai_enhancement/templates/delete_enhancement.html @@ -0,0 +1,66 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block breadcrumbs %} + {{ block.super }} + + +{% endblock breadcrumbs %} + +{% block page_content %} +

{% trans 'To delete this enhancement, please check the box and click confirm. The video data will also be deleted from Aristote AI.' %}

+
+ {% csrf_token %} +
+
+ {% trans 'Agreement required' %} + {% if form.errors %} +

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

+ {% endif %} + {% for field_hidden in form.hidden_fields %} + {{field_hidden}} + {% endfor %} + {% for field in form.visible_fields %} + {% spaceless %} +
+
+ {{ field.errors }} +
+ {{ field }} +
+ {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} + {% if field.field.required %} +
{% trans 'Please provide a valid value for this field.' %}
+ {% endif %} +
+
+ {% endspaceless %} + {% endfor %} +
+ +  {% trans 'Back to the video' %} + + +
+
+
+
+{% endblock page_content %} + +{% block collapse_page_aside %} +{% endblock collapse_page_aside %} + +{% block page_aside %} +{% endblock page_aside %} diff --git a/pod/ai_enhancement/templatetags/__init__.py b/pod/ai_enhancement/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pod/ai_enhancement/templatetags/ai_enhancement_template_tags.py b/pod/ai_enhancement/templatetags/ai_enhancement_template_tags.py new file mode 100644 index 0000000000..743e064e75 --- /dev/null +++ b/pod/ai_enhancement/templatetags/ai_enhancement_template_tags.py @@ -0,0 +1,82 @@ +"""Template tags for the AI enhancement app.""" + +from django.conf import settings +from django.template import Library + + +from pod.ai_enhancement.utils import ( + enhancement_is_ready as eir, + enhancement_is_already_asked as eia, +) +from pod.video.models import Video + + +USE_AI_ENHANCEMENT = getattr(settings, "USE_AI_ENHANCEMENT", False) +AI_ENHANCEMENT_TO_STAFF_ONLY = getattr(settings, "AI_ENHANCEMENT_TO_STAFF_ONLY", True) + +register = Library() + + +@register.simple_tag(takes_context=True, name="user_can_enhance_video") +def user_can_enhance_video(context: dict, video: Video) -> bool: + """ + Template tag used to check if the user can enrich a specific video. + + Args: + context (dict): The context. + video (:class:`pod.video.models.Video`): The specific video. + + Returns: + bool: `True` if the user can do it. `False` otherwise. + """ + request = context["request"] + if not request.user.is_authenticated: + return False + if ( + request.user.is_authenticated + and AI_ENHANCEMENT_TO_STAFF_ONLY + and request.user.is_staff is False + ): + return False + return (request.user.is_staff or request.user.is_superuser) and USE_AI_ENHANCEMENT + + +@register.simple_tag(takes_context=True, name="enhancement_is_ready") +def enhancement_is_ready(context: dict, video: Video) -> bool: + """ + Template tag used to check if the enhancement of a specific video is ready. + + Args: + context (dict): The context. + video (:class:`pod.video.models.Video`): The specific video. + + Returns: + bool: `True` if the enhancement is ready. `False` otherwise. + """ + request = context["request"] + if not request.user.is_authenticated: + return False + return eir(video) and USE_AI_ENHANCEMENT and user_can_enhance_video(context, video) + + +@register.simple_tag(takes_context=True, name="enhancement_is_already_asked") +def enhancement_is_already_asked(context: dict, video: Video) -> bool: + """ + Template tag used to check if the enhancement of a specific video is already asked. + + Args: + context (dict): The context. + video (:class:`pod.video.models.Video`): The specific video. + + Returns: + bool: `True` if the enhancement is already asked. `False` otherwise. + """ + request = context["request"] + if not request.user.is_authenticated: + return False + return ( + eia(video) + and USE_AI_ENHANCEMENT + and user_can_enhance_video(context, video) + and not eir(video) + ) diff --git a/pod/ai_enhancement/tests/__init__.py b/pod/ai_enhancement/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pod/ai_enhancement/tests/test_models.py b/pod/ai_enhancement/tests/test_models.py new file mode 100644 index 0000000000..a6ff38bbb3 --- /dev/null +++ b/pod/ai_enhancement/tests/test_models.py @@ -0,0 +1,46 @@ +"""Tests the models for ai_enhancement module.""" +from django.contrib.auth.models import User +from django.test import TestCase + +from pod.ai_enhancement.models import AIEnhancement +from pod.video.models import Video, Type + + +class AIEnrichmentModelTest(TestCase): + """ + Test the AIEnrichment model. + + Args: + TestCase (::class::`django.test.TestCase`): The test case. + """ + + fixtures = [ + "initial_data.json", + ] + + def setUp(self): + """Set up the tests.""" + self.owner = User.objects.create_user(username="testuser") + self.video = Video.objects.create( + owner=self.owner, + type=Type.objects.create(title="Test Type"), + ) + self.ai_enhancement = AIEnhancement.objects.create( + video=self.video, ai_enhancement_id_in_aristote="test_id" + ) + + def test_create_ai_enhancement(self): + """Test the model creation.""" + self.assertEqual(self.ai_enhancement.video, self.video) + self.assertEqual(self.ai_enhancement.ai_enhancement_id_in_aristote, "test_id") + print(" ---> test_create_ai_enhancement ok") + + def test_str(self): + """Test the string representation.""" + self.assertEqual(str(self.ai_enhancement), f"{self.video.title} - test_id") + print(" ---> test_str ok") + + def test_sites(self): + """Test the sites property.""" + self.assertEqual(self.ai_enhancement.sites, self.video.sites) + print(" ---> test_sites ok") diff --git a/pod/ai_enhancement/tests/test_utils.py b/pod/ai_enhancement/tests/test_utils.py new file mode 100644 index 0000000000..bd66766c9c --- /dev/null +++ b/pod/ai_enhancement/tests/test_utils.py @@ -0,0 +1,336 @@ +"""Tests the util functions and classes for ai_enhancement module.""" +from unittest.mock import patch + +from django.test import TestCase +from requests import Response + +from pod.ai_enhancement.utils import AristoteAI +from pod.main.utils import extract_json_from_str +from pod.video.models import Discipline + + +class AristoteAITestCase(TestCase): + """TestCase for Esup-Pod AI Enhancement utilities.""" + + fixtures = ["initial_data.json"] + + def setUp(self): + """Set up the tests.""" + self.client_id = "client_id" + self.client_secret = "client_secret" + for i in range(1, 6): + Discipline.objects.create(title=f"mocked_discipline_0{i}") + + @patch("requests.post") + def test_connect_to_api__success(self, mock_post): + """Test the connect_to_api method when the request is successful.""" + mock_response = Response() + mock_response.status_code = 200 + mock_response.json = lambda: {"access_token": "mocked_token"} + mock_post.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + result = aristote_ai.connect_to_api() + self.assertEqual(result, {"access_token": "mocked_token"}) + self.assertEqual(aristote_ai.token, "mocked_token") + print(" ---> test_connect_to_api__success ok") + + @patch("requests.post") + def test_connect_to_api__failure(self, mock_post): + """Test the connect_to_api method when the request fails.""" + mock_response = Response() + mock_response.status_code = 401 + mock_post.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + result = aristote_ai.connect_to_api() + self.assertEqual(result, mock_response) + self.assertIsNone(aristote_ai.token) + print(" ---> test_connect_to_api__failure ok") + + @patch("requests.get") + def test_get_ai_enhancements__success(self, mock_get): + """Test the get_ai_enhancements method when the request is successful.""" + mock_response = Response() + mock_response.status_code = 200 + mock_response.json = lambda: {"content": "mocked_content"} + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_ai_enhancements() + self.assertEqual(result, {"content": "mocked_content"}) + print(" ---> test_get_ai_enhancements__success ok") + + @patch("requests.get") + def test_get_ai_enhancements__failure(self, mock_get): + """Test the get_ai_enhancements method when the request fails.""" + mock_response = Response() + mock_response.status_code = 401 + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_ai_enhancements() + self.assertEqual(result, mock_response) + print(" ---> test_get_ai_enhancements__failure ok") + + @patch("requests.get") + def test_get_specific_ai_enhancement__success(self, mock_get): + """Test the get_specific_ai_enhancement method when the request is successful.""" + content = { + "id": "mocked_id", + "status": "mocked_status", + "failureCause": None, + "media": { + "originalFileName": "mocked_originalFileName", + "mineType": "mocked_mineType", + }, + "notificationStatus": None, + "disciplines": [ + "mocked_discipline_01", + "mocked_discipline_02", + "mocked_discipline_03", + "mocked_discipline_04", + "mocked_discipline_05", + ], + "mediaTypes": ["mocked_mediaType_01", "mocked_mediaType_02"], + "notifiedAt": None, + "aiEvaluation": "mocked_aiEvaluation", + "endUserIdentifier": "mocked_endUserIdentifier", + "initialVersionId": "mocked_initialVersionId", + } + mock_response = Response() + mock_response.status_code = 200 + mock_response.json = lambda: content + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_specific_ai_enhancement("mocked_id") + self.assertEqual(result, content) + print(" ---> test_get_specific_ai_enhancement__success ok") + + @patch("requests.get") + def test_get_specific_ai_enhancement__failure(self, mock_get): + """Test the get_specific_ai_enhancement method when the request fails.""" + mock_response = Response() + mock_response.status_code = 401 + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_specific_ai_enhancement("mocked_id") + self.assertEqual(result, mock_response) + print(" ---> test_get_specific_ai_enhancement__failure ok") + + @patch("requests.post") + def test_create_enhancement_from_url__success(self, mock_post): + """Test the create_enhancement_from_url method when the request is successful.""" + mock_response = Response() + mock_response.status_code = 200 + mock_response.json = lambda: { + "status": "OK", + "id": "mocked_id", + } + mock_post.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.create_enhancement_from_url( + "mocked_url", + ["mocked_media_type_1", "mocked_media_type_2"], + "mocked_end_user_identifier", + "mocked_notification_webhook_url", + ) + self.assertIsNone(result) + print(" ---> test_create_enhancement_from_url__success ok") + + @patch("requests.post") + def test_create_enhancement_from_url__failure(self, mock_post): + """Test the create_enhancement_from_url method when the request fails.""" + mock_response = Response() + mock_response.status_code = 401 + mock_post.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.create_enhancement_from_url( + "mocked_url", + ["mocked_media_type_1", "mocked_media_type_2"], + "mocked_end_user_identifier", + "mocked_notification_webhook_url", + ) + self.assertIsNone(result) + print(" ---> test_create_enhancement_from_url__failure ok") + + @patch("requests.get") + def test_get_latest_enhancement_version__success(self, mock_get): + """Test the get_latest_enhancement_version method when the request is successful.""" + mock_response = Response() + mock_response.status_code = 200 + mock_response.json = lambda: { + "createdAt": "2024-01-26T14:40:05+01:00", + "updatedAt": "2024-01-26T14:40:05+01:00", + "id": "018d45ff-bfe7-772f-b671-723ac7de674e", + "enhancementVersionMetadata": { + "title": "Worker enhancement", + "description": "This is an example of an enhancement version", + "topics": [ + "Random topic 1", + "Random topic 2", + ], + "discipline": "mocked_discipline", + "mediaType": "mocked_mediaType", + }, + "transcript": { + "originalFilename": "transcript.json", + "language": "fr", + "text": "mocked_text", + "sentences": [], + }, + "multipleChoiceQuestions": [], + "initialVersion": True, + } + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_latest_enhancement_version("mocked_id") + self.assertEqual(result, mock_response.json()) + print(" ---> test_get_latest_enhancement_version__success ok") + + @patch("requests.get") + def test_get_latest_enhancement_version__failure(self, mock_get): + """Test the get_latest_enhancement_version method when the request fails.""" + mock_response = Response() + mock_response.status_code = 401 + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_latest_enhancement_version("mocked_id") + self.assertEqual(result, mock_response) + print(" ---> test_get_latest_enhancement_version__failure ok") + + @patch("requests.get") + def test_get_enhancement_versions__success(self, mock_get): + """Test the get_enhancement_versions method when the request is successful.""" + mock_response = Response() + mock_response.status_code = 200 + mock_response.json = lambda: { + "content": [], + "totalElements": 0, + "currentPage": 1, + "isLastPage": True, + } + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_enhancement_versions("mocked_id") + self.assertEqual(result, mock_response.json()) + print(" ---> test_get_latest_enhancement_version__success ok") + + @patch("requests.get") + def test_get_enhancement_versions__failure(self, mock_get): + """Test the get_enhancement_versions method when the request fails.""" + mock_response = Response() + mock_response.status_code = 401 + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_enhancement_versions("mocked_id") + self.assertEqual(result, mock_response) + print(" ---> test_get_latest_enhancement_version__failure ok") + + @patch("requests.get") + def test_get_specific_enhancement_version__success(self, mock_get): + """Test the get_specific_enhancement_version method when the request is successful.""" + mock_response = Response() + mock_response.status_code = 200 + mock_response.json = lambda: { + "createdAt": "2024-01-26T14:40:05+01:00", + "updatedAt": "2024-01-26T14:40:05+01:00", + "id": "mocked_id", + "enhancementVersionMetadata": { + "title": "Worker enhancement", + "description": "This is an example of an enhancement version", + "topics": [ + "Random topic 1", + "Random topic 2", + ], + "discipline": "mocked_discipline", + "mediaType": "mocked_mediaType", + }, + "transcript": { + "originalFilename": "transcript.json", + "language": "fr", + "text": "mocked_text", + "sentences": [], + }, + "multipleChoiceQuestions": [], + "initialVersion": True, + "lastEvaluationDate": "mocked_last_evaluation_date", + } + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_specific_enhancement_version( + "mocked_enhancement_id", + "mocked_version_id", + ) + self.assertEqual(result, mock_response.json()) + print(" ---> get_specific_enhancement_version__success ok") + + @patch("requests.get") + def test_get_specific_enhancement_version__failure(self, mock_get): + """Test the get_specific_enhancement_version method when the request fails.""" + mock_response = Response() + mock_response.status_code = 401 + mock_get.return_value = mock_response + + aristote_ai = AristoteAI(self.client_id, self.client_secret) + aristote_ai.token = "mocked_token" + result = aristote_ai.get_specific_enhancement_version( + "mocked_enhancement_id", "mocked_version_id" + ) + self.assertEqual(result, mock_response) + print(" ---> test_get_specific_enhancement_version__failure ok") + + def test_extract_json_from_str__valid_json(self): + """Test the extract_json_from_str function with a valid JSON string.""" + content_to_extract = 'This is some text {"key": "value"} and more text.' + result = extract_json_from_str(content_to_extract) + self.assertEqual(result, {"key": "value"}) + print(" ---> test_extract_json_from_str__valid_json ok") + + def test_extract_json_from_str__invalid_json(self): + """Test the extract_json_from_str function with an invalid JSON string.""" + content_to_extract = 'This is some text {"key": "value" and more text.' + result = extract_json_from_str(content_to_extract) + expected_result = { + "error": "JSONDecodeError: The string is not a valid JSON string." + } + self.assertEqual(result, expected_result) + print(" ---> test_extract_json_from_str__invalid_json ok") + + def test_extract_json_from_str__no_json(self): + """Test the extract_json_from_str function with a string without JSON content.""" + content_to_extract = "This is some text without JSON content." + result = extract_json_from_str(content_to_extract) + expected_result = { + "error": "JSONDecodeError: The string is not a valid JSON string." + } + self.assertEqual(result, expected_result) + print(" ---> test_extract_json_from_str__no_json ok") + + def test_get_token(self): + """Test the get_token method.""" + aristote = AristoteAI(self.client_id, self.client_secret) + aristote.connect_to_api = lambda: setattr(aristote, "token", "mocked_token") + token = aristote.get_token() + self.assertEqual(token, "mocked_token") + print(" ---> test_get_token__success ok") diff --git a/pod/ai_enhancement/tests/test_views.py b/pod/ai_enhancement/tests/test_views.py new file mode 100644 index 0000000000..7c531c22e8 --- /dev/null +++ b/pod/ai_enhancement/tests/test_views.py @@ -0,0 +1,282 @@ +"""Tests the views for ai_enhancement module.""" +import json +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.http import JsonResponse +from django.test import RequestFactory, TestCase +from django.urls import reverse + +from pod.ai_enhancement.models import AIEnhancement +from pod.ai_enhancement.views import toggle_webhook +from pod.main.models import Configuration +from pod.video.models import Video, Type + + +class EnrichVideoJsonViewTest(TestCase): + """Test the enhance_video_json view.""" + + fixtures = ["initial_data.json"] + + def setUp(self): + """Set up the test.""" + self.factory = RequestFactory() + self.user = User.objects.create_user(username="testuser") + self.user.is_staff = True + self.user.save() + self.video = Video.objects.create( + slug="test-video", + owner=self.user, + video="test_video.mp4", + title="Test video", + description="This is a test video.", + type=Type.objects.get(id=1), + ) + self.enhancement = AIEnhancement.objects.create( + video=self.video, ai_enhancement_id_in_aristote="123" + ) + self.client.force_login(self.user) + + @patch("pod.ai_enhancement.views.AristoteAI") + def test_enhance_video_json__success(self, mock_aristote_ai): + """Test the enhance_video_json view when successful.""" + json_data = { + "createdAt": "2024-01-26T14:40:05+01:00", + "updatedAt": "2024-01-26T14:40:05+01:00", + "id": "018d45ff-bfe7-772f-b671-723ac7de674e", + "enhancementVersionMetadata": { + "title": "Random title", + "description": "This is an example of an enhancement version", + "topics": [ + "Random topic 1", + "Random topic 2", + ], + "discipline": "mocked_discipline", + "mediaType": "mocked_mediaType", + }, + "transcript": { + "originalFilename": "transcript.json", + "language": "fr", + "text": "mocked_text", + "sentences": [], + }, + "multipleChoiceQuestions": [], + "initialVersion": True, + } + mock_aristote_instance = mock_aristote_ai.return_value + mock_aristote_instance.get_latest_enhancement_version.return_value = json_data + url = reverse("ai_enhancement:enhance_video_json", args=[self.video.slug]) + # request = self.factory.get(url) + response = self.client.get(url) + # response = enhance_video_json(request, self.video.slug) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, JsonResponse) + mock_aristote_instance.get_latest_enhancement_version.assert_called_once_with( + self.enhancement.ai_enhancement_id_in_aristote, + ) + expected_json = json_data + self.assertJSONEqual(str(response.content, encoding="utf-8"), expected_json) + print(" ---> test_enhance_video_json__success ok") + + +class ReceiveWebhookViewTest(TestCase): + """Test the receive_webhook view.""" + + fixtures = ["initial_data.json"] + + def setUp(self): + """Set up the test.""" + self.factory = RequestFactory() + self.user = User.objects.create_user(username="test_user") + self.user.is_staff = True + self.user.save() + self.video = Video.objects.create( + slug="test-video", + owner=self.user, + video="test_video.mp4", + title="Test video", + description="This is a test video.", + type=Type.objects.get(id=1), + ) + self.enhancement = AIEnhancement.objects.create( + video=self.video, ai_enhancement_id_in_aristote="123" + ) + self.client.force_login(self.user) + + def test_toggle_webhook__success(self): + """Test the receive_webhook view when successful.""" + url = reverse("ai_enhancement:webhook") + request_data = { + "id": "123", + "status": "SUCCESS", + "initialVersionId": "018e08b5-9ea0-73a7-bcd7-34764e3b0775", + "failureCause": None, + } + request = self.factory.post( + url, data=request_data, content_type="application/json" + ) + response = toggle_webhook(request) + self.enhancement.refresh_from_db() + self.assertTrue(self.enhancement.is_ready) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, JsonResponse) + self.assertEqual(json.loads(response.content.decode()), {"status": "OK"}) + print(" ---> test_toggle_webhook__success ok") + + def test_toggle_webhook__bad_method(self): + """Test the receive_webhook view when using a bad method.""" + url = reverse("ai_enhancement:webhook") + request = self.factory.get(url) + response = toggle_webhook(request) + self.enhancement.refresh_from_db() + self.assertFalse(self.enhancement.is_ready) + self.assertEqual(response.status_code, 405) + self.assertIsInstance(response, JsonResponse) + self.assertEqual( + json.loads(response.content.decode()), + {"error": "Only POST requests are allowed."}, + ) + print(" ---> test_toggle_webhook__bad_method ok") + + def test_toggle_webhook__enhancement_not_found(self): + """Test the receive_webhook view when the enhancement is not found.""" + url = reverse("ai_enhancement:webhook") + request_data = { + "id": "456", + "status": "SUCCESS", + "initialVersionId": "018e08b5-9ea0-73a7-bcd7-34764e3b0775", + "failureCause": None, + } + request = self.factory.post( + url, data=request_data, content_type="application/json" + ) + response = toggle_webhook(request) + self.enhancement.refresh_from_db() + self.assertFalse(self.enhancement.is_ready) + self.assertEqual(response.status_code, 404) + self.assertIsInstance(response, JsonResponse) + self.assertEqual( + json.loads(response.content.decode()), {"error": "Enhancement not found."} + ) + print(" ---> test_toggle_webhook__enhancement_not_found ok") + + def test_toggle_webhook__no_id_in_request(self): + """Test the receive_webhook view when there is no id in the request.""" + url = reverse("ai_enhancement:webhook") + request_data = { + "status": "SUCCESS", + "initialVersionId": "018e08b5-9ea0-73a7-bcd7-34764e3b0775", + "failureCause": None, + } + request = self.factory.post( + url, data=request_data, content_type="application/json" + ) + response = toggle_webhook(request) + self.enhancement.refresh_from_db() + self.assertFalse(self.enhancement.is_ready) + self.assertEqual(response.status_code, 400) + self.assertIsInstance(response, JsonResponse) + self.assertEqual( + json.loads(response.content.decode()), {"error": "No id in the request."} + ) + print(" ---> test_toggle_webhook__no_id_in_request ok") + + def test_toggle_webhook__bad_content_type(self): + """Test the receive_webhook view when using a bad content type.""" + url = reverse("ai_enhancement:webhook") + request_data = { + "status": "SUCCESS", + "initialVersionId": "018e08b5-9ea0-73a7-bcd7-34764e3b0775", + "failureCause": None, + } + request = self.factory.post(url, data=request_data, content_type="image/png") + response = toggle_webhook(request) + self.enhancement.refresh_from_db() + self.assertFalse(self.enhancement.is_ready) + self.assertEqual(response.status_code, 415) + self.assertIsInstance(response, JsonResponse) + self.assertEqual( + json.loads(response.content.decode()), + {"error": "Only application/json content type is allowed."}, + ) + print(" ---> test_toggle_webhook__bad_content_type ok") + + def test_toggle_webhook__enrichment_not_achieved(self): + """Test the receive_webhook view when the enrichment has not been achieved.""" + url = reverse("ai_enhancement:webhook") + request_data = { + "id": "123", + "status": "FAILURE", + "initialVersionId": "018e08b5-9ea0-73a7-bcd7-34764e3b0775", + "failureCause": "mocked_failure_cause", + } + request = self.factory.post( + url, data=request_data, content_type="application/json" + ) + response = toggle_webhook(request) + self.enhancement.refresh_from_db() + self.assertFalse(self.enhancement.is_ready) + self.assertEqual(response.status_code, 500) + self.assertIsInstance(response, JsonResponse) + self.assertEqual( + json.loads(response.content.decode()), + {"status": "Enhancement has not yet been successfully achieved."}, + ) + print(" ---> test_toggle_webhook__enrichment_not_achieved ok") + + +class EnhanceVideoViewTest(TestCase): + """Test the enhance_video view.""" + + fixtures = ["initial_data.json"] + + def setUp(self): + """Set up the test.""" + self.factory = RequestFactory() + self.user = User.objects.create_user(username="test_user") + self.user.is_staff = True + self.user.save() + self.video = Video.objects.create( + slug="test-video", + owner=self.user, + video="test_video.mp4", + title="Test video", + description="This is a test video.", + type=Type.objects.get(id=1), + ) + self.client.force_login(self.user) + + def test_enhance_video__success(self): + """Test the enhance_video view when success.""" + url = reverse("ai_enhancement:enhance_video", args=[self.video.slug]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "base.html") + self.assertTemplateUsed(response, "navbar.html") + self.assertTemplateUsed(response, "navbar_collapse.html") + self.assertTemplateUsed(response, "footer.html") + self.assertTemplateUsed(response, "create_enhancement.html") + print(" ---> test_enhance_video__success ok") + + def test_enhance_video__video_not_exists(self): + """Test the enhance_video view when the video not exists.""" + url = reverse("ai_enhancement:enhance_video", args=["fake-slug"]) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + print(" ---> test_enhance_video__video_not_exists ok") + + def test_enhance_video__in_maintenance(self): + """Test the enhance_video view when in maintenance.""" + maintenance_mode_conf = Configuration.objects.get(key="maintenance_mode") + maintenance_mode_conf.value = "1" + maintenance_mode_conf.save() + print( + "MAINTENANCE MODE: ", + True + if Configuration.objects.get(key="maintenance_mode").value == "1" + else False, + ) + url = reverse("ai_enhancement:enhance_video", args=[self.video.slug]) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + print(" ---> test_enhance_video__in_maintenance ok") diff --git a/pod/ai_enhancement/urls.py b/pod/ai_enhancement/urls.py new file mode 100644 index 0000000000..2ad9d43b26 --- /dev/null +++ b/pod/ai_enhancement/urls.py @@ -0,0 +1,30 @@ +"""URL patterns used in ai_enhancement application.""" +from django.urls import path + +from pod.ai_enhancement.views import ( + delete_enhance_video, + enhance_subtitles, + enhance_quiz, + enhance_video, + enhance_video_json, + toggle_webhook, +) + +app_name = "ai_enhancement" + +urlpatterns = [ + path("webhook/", toggle_webhook, name="webhook"), + path("delete//", delete_enhance_video, name="delete_enhance_video"), + path("enhance_video//", enhance_video, name="enhance_video"), + path( + "enhance_subtitles//", + enhance_subtitles, + name="enhance_subtitles", + ), + path("enhance_quiz//", enhance_quiz, name="enhance_quiz"), + path( + "enhance_video_json//json/", + enhance_video_json, + name="enhance_video_json", + ), +] diff --git a/pod/ai_enhancement/utils.py b/pod/ai_enhancement/utils.py new file mode 100644 index 0000000000..e3abdde3b2 --- /dev/null +++ b/pod/ai_enhancement/utils.py @@ -0,0 +1,366 @@ +"""Util functions and classes for ai_enhancement module.""" +import json +import bleach +import requests +import logging +from django.conf import settings +from requests import Response +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ +from pod.ai_enhancement.models import AIEnhancement +from pod.main.utils import extract_json_from_str +from pod.video.models import Discipline, Video +from webpush.models import PushInformation +from django.core.mail import send_mail +from django.core.mail import mail_managers +from django.core.mail import EmailMultiAlternatives + +DEBUG = getattr(settings, "DEBUG", True) +DEFAULT_FROM_EMAIL = getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@univ.fr") + +USE_ESTABLISHMENT_FIELD = getattr(settings, "USE_ESTABLISHMENT_FIELD", False) + +MANAGERS = getattr(settings, "MANAGERS", {}) + +SECURE_SSL_REDIRECT = getattr(settings, "SECURE_SSL_REDIRECT", False) + +AI_ENHANCEMENT_API_URL = getattr(settings, "AI_ENHANCEMENT_API_URL", "") +AI_ENHANCEMENT_API_VERSION = getattr(settings, "AI_ENHANCEMENT_API_VERSION", "") +USE_NOTIFICATIONS = getattr(settings, "USE_NOTIFICATIONS", True) +EMAIL_ON_ENHANCEMENT_COMPLETION = getattr( + settings, "EMAIL_ON_ENHANCEMENT_COMPLETION", True +) +TEMPLATE_VISIBLE_SETTINGS = getattr( + settings, + "TEMPLATE_VISIBLE_SETTINGS", + { + "TITLE_SITE": "Pod", + "TITLE_ETB": "University name", + "LOGO_SITE": "img/logoPod.svg", + "LOGO_ETB": "img/esup-pod.svg", + "LOGO_PLAYER": "img/pod_favicon.svg", + "LINK_PLAYER": "", + "LINK_PLAYER_NAME": _("Home"), + "FOOTER_TEXT": ("",), + "FAVICON": "img/pod_favicon.svg", + "CSS_OVERRIDE": "", + "PRE_HEADER_TEMPLATE": "", + "POST_FOOTER_TEMPLATE": "", + "TRACKING_TEMPLATE": "", + }, +) + +__TITLE_SITE__ = ( + TEMPLATE_VISIBLE_SETTINGS["TITLE_SITE"] + if (TEMPLATE_VISIBLE_SETTINGS.get("TITLE_SITE")) + else "Pod" +) + +logger = logging.getLogger(__name__) + + +class AristoteAI: + """Aristote AI Enhancement utilities.""" + + def __init__(self, client_id, client_secret): + self.client_id = client_id + self.client_secret = client_secret + self.token = None + + def get_token(self): + """Get the token.""" + if self.token is None: + self.connect_to_api() + return self.token + + def connect_to_api(self) -> Response or None: + """Connect to the API.""" + path = "/token" + data = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + } + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + try: + response = requests.post( + AI_ENHANCEMENT_API_URL + path, + data=json.dumps(data), + headers=headers, + ) + if response.status_code == 200: + self.token = response.json()["access_token"] + return response.json() + else: + logger.error(f"Error: {response.status_code}") + return response + except requests.exceptions.RequestException as e: + logger.error(f"Request Exception: {e}") + return None + + def get_response(self, path: str) -> dict or None: + """ + Get the AI response. + + Args: + path (str): The path to the API endpoint. + """ + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.get_token()}", + } + try: + response = requests.get( + AI_ENHANCEMENT_API_URL + path, + headers=headers, + ) + if response.status_code == 200: + return response.json() + else: + logger.error(f"Error: {response.status_code}") + return response + except requests.exceptions.RequestException as e: + logger.error(f"Request Exception: {e}") + return None + + def get_ai_enhancements(self) -> dict or None: + """Get the AI enhancements.""" + path = f"/{AI_ENHANCEMENT_API_VERSION}/enrichments" + return self.get_response(path) + + def get_specific_ai_enhancement(self, enhancement_id: str) -> dict or None: + """ + Get a specific AI enhancement. + + Args: + enhancement_id (str): The enhancement id. + """ + path = f"/{AI_ENHANCEMENT_API_VERSION}/enrichments/{enhancement_id}" + return self.get_response(path) + + def create_enhancement_from_url( + self, + url: str, + media_types: list, + end_user_identifier: str, + notification_webhook_url: str, + ) -> dict or None: + """Create an enhancement from a file.""" + if Discipline.objects.count() > 0: + path = f"/{AI_ENHANCEMENT_API_VERSION}/enrichments/url" + data = { + "url": url, + "notificationWebhookUrl": notification_webhook_url, + "enrichmentParameters": { + "mediaTypes": media_types, + "disciplines": list( + Discipline.objects.all().values_list("title", flat=True) + ), + # "aiEvaluation": "true" # TODO: change this + }, + "enduserIdentifier": end_user_identifier, + } + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {self.get_token()}", + } + try: + response = requests.post( + AI_ENHANCEMENT_API_URL + path, + data=json.dumps(data), + headers=headers, + ) + if response.status_code == 200: + return ( + extract_json_from_str(response.content.decode("utf-8")) + if response.content + else None + ) + else: + logger.error(f"Error: {response.status_code}") + return None + except requests.exceptions.RequestException as e: + logger.error(f"Request Exception: {e}") + return None + else: + raise ValueError("No discipline in the database.") + + def get_latest_enhancement_version(self, enhancement_id: str) -> dict or None: + """Get the latest enhancement version.""" + path = ( + f"/{AI_ENHANCEMENT_API_VERSION}/enrichments/{enhancement_id}/versions/latest" + ) + return self.get_response(path) + + def get_enhancement_versions( + self, enhancement_id: str, with_transcript: bool = True + ) -> dict or None: + """Get the enhancement versions.""" + path = f"/{AI_ENHANCEMENT_API_VERSION}/enrichments/{enhancement_id}/versions?withTranscript={with_transcript}" + return self.get_response(path) + + def get_specific_enhancement_version( + self, enhancement_id: str, version_id: str + ) -> dict or None: + """Get a specific version.""" + path = f"/{AI_ENHANCEMENT_API_VERSION}/enrichments/{enhancement_id}/versions/{version_id}" + return self.get_response(path) + + def delete_request(self, path: str) -> dict or None: + """ + Send delete request. + + Args: + path (str): The path to the API endpoint. + """ + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.get_token()}", + } + try: + response = requests.delete( + AI_ENHANCEMENT_API_URL + path, + headers=headers, + ) + if response.status_code == 200: + return response.json() + else: + logger.error(f"Error: {response.status_code}") + return response + except requests.exceptions.RequestException as e: + logger.error(f"Request Exception: {e}") + return None + + def delete_enhancement(self, enhancement_id: str) -> dict or None: + """Delete the specific enhancement.""" + path = f"/{AI_ENHANCEMENT_API_VERSION}/enrichments/{enhancement_id}" + return self.delete_request(path) + + +def enhancement_is_already_asked(video: Video) -> bool: + """Check if the enhancement is already asked.""" + return AIEnhancement.objects.filter(video=video).exists() + + +def enhancement_is_ready(video: Video) -> bool: + """Check if the enhancement is ready.""" + return AIEnhancement.objects.filter(video=video, is_ready=True).exists() + + +def notify_user(video: Video): + """Notify user at the end of enhancement.""" + if ( + USE_NOTIFICATIONS + and video.owner.owner.accepts_notifications + and PushInformation.objects.filter(user=video.owner).exists() + ): + send_notification_enhancement(video) + if EMAIL_ON_ENHANCEMENT_COMPLETION: + send_email_enhancement(video) + + +def send_notification_enhancement(video): + """Send push notification on video encoding or transcripting completion.""" + subject = "[%s] %s" % ( + __TITLE_SITE__, + _("%(subject)s #%(content_id)s completed") + % {"subject": _("Enhancement"), "content_id": video.id}, + ) + message = _( + "“%(content_title)s” was processed by the AI." + + " Suggestions for improvement are available on %(site_title)s." + ) % { + "content_title": video.title, + "site_title": __TITLE_SITE__, + } + + notify_user( + video.owner, + subject, + message, + url=reverse("video:video", args=(video.slug,)), + ) + + +def send_email_enhancement(video): + """Send email notification on video improvement completion.""" + if DEBUG: + logger.info("SEND EMAIL ON IA IMPROVEMENT COMPLETION") + url_scheme = "https" if SECURE_SSL_REDIRECT else "http" + content_url = "%s:%s" % (url_scheme, video.get_full_url()) + subject = "[%s] %s" % ( + __TITLE_SITE__, + _("IA improvement #%(content_id)s completed") % {"content_id": video.id}, + ) + + html_message = ( + '

%s

%s

%s
%s\ +

%s

' + % ( + _("Hello,"), + _( + "IA improvement “%(content_title)s” has been completed" + + ", and is now available on %(site_title)s." + ) + % { + "content_title": "%s" % video.title, + "site_title": __TITLE_SITE__, + }, + _("You will find it here:"), + content_url, + content_url, + _("Regards."), + ) + ) + + full_html_message = html_message + "
%s%s
%s%s" % ( + _("Post by:"), + video.owner, + _("the:"), + video.date_added, + ) + + message = bleach.clean(html_message, tags=[], strip=True) + full_message = bleach.clean(full_html_message, tags=[], strip=True) + + from_email = DEFAULT_FROM_EMAIL + to_email = [] + to_email.append(video.owner.email) + + if ( + USE_ESTABLISHMENT_FIELD + and MANAGERS + and video.owner.owner.establishment.lower() in dict(MANAGERS) + ): + bcc_email = [] + video_estab = video.owner.owner.establishment.lower() + manager = dict(MANAGERS)[video_estab] + if isinstance(manager, (list, tuple)): + bcc_email = manager + elif isinstance(manager, str): + bcc_email.append(manager) + msg = EmailMultiAlternatives( + subject, message, from_email, to_email, bcc=bcc_email + ) + msg.attach_alternative(html_message, "text/html") + msg.send() + else: + mail_managers( + subject, + full_message, + fail_silently=False, + html_message=full_html_message, + ) + if not DEBUG: + send_mail( + subject, + message, + from_email, + to_email, + fail_silently=False, + html_message=html_message, + ) diff --git a/pod/ai_enhancement/views.py b/pod/ai_enhancement/views.py new file mode 100644 index 0000000000..e9abd76dff --- /dev/null +++ b/pod/ai_enhancement/views.py @@ -0,0 +1,476 @@ +"""Views for Esup-Pod ai_enhancement module.""" +import json +import hashlib +from django.conf import settings +from django.contrib import messages +from django.contrib.sites.shortcuts import get_current_site +from django.core.handlers.wsgi import WSGIRequest +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import ugettext as _ +from django.views.decorators.csrf import csrf_protect, csrf_exempt +from django.views.decorators.csrf import ensure_csrf_cookie +from django.core.exceptions import PermissionDenied +from django.contrib.auth.decorators import login_required +from pod.ai_enhancement.forms import ( + AIEnhancementChoice, + NotifyUserThirdPartyServicesForm, + NotifyUserDeleteEnhancementForm, +) +from pod.ai_enhancement.models import AIEnhancement +from pod.ai_enhancement.utils import AristoteAI, enhancement_is_already_asked, notify_user +from pod.completion.models import Track +from pod.main.lang_settings import ALL_LANG_CHOICES, PREF_LANG_CHOICES +from pod.main.utils import json_to_web_vtt +from pod.main.views import in_maintenance +from pod.podfile.models import UserFolder +from pod.quiz.utils import import_quiz +from pod.video.models import Video, Discipline +from pod.video_encode_transcript.transcript import saveVTT + +AI_ENHANCEMENT_CLIENT_ID = getattr(settings, "AI_ENHANCEMENT_CLIENT_ID", "mocked_id") +AI_ENHANCEMENT_CLIENT_SECRET = getattr( + settings, "AI_ENHANCEMENT_CLIENT_SECRET", "mocked_secret" +) +AI_ENHANCEMENT_TO_STAFF_ONLY = getattr(settings, "AI_ENHANCEMENT_TO_STAFF_ONLY", True) +LANG_CHOICES = getattr( + settings, + "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] +} + + +@csrf_exempt +def toggle_webhook(request: WSGIRequest): + """Receive webhook from the AI Enhancement service.""" + if request.method != "POST": + return JsonResponse({"error": "Only POST requests are allowed."}, status=405) + if "application/json" not in request.headers.get("Content-Type"): + return JsonResponse( + {"error": "Only application/json content type is allowed."}, status=415 + ) + if "application/json" in request.headers.get("Content-Type"): + data = json.loads(request.body) + if "id" in data: + enhancement = AIEnhancement.objects.filter( + ai_enhancement_id_in_aristote=data["id"] + ).first() + if enhancement: + if "status" in data and data["status"] == "SUCCESS": + enhancement.is_ready = True + enhancement.save() + notify_user(enhancement.video) + return JsonResponse({"status": "OK"}, status=200) + else: + return JsonResponse( + {"status": "Enhancement has not yet been successfully achieved."}, + status=500, + ) + else: + return JsonResponse({"error": "Enhancement not found."}, status=404) + else: + return JsonResponse({"error": "No id in the request."}, status=400) + + +def send_enhancement_creation_request( + request: WSGIRequest, aristote: AristoteAI, video: Video +) -> HttpResponse: + """Send a request to create an enhancement.""" + if request.method == "POST": + form = NotifyUserThirdPartyServicesForm(request.POST) + if form.is_valid(): + url_scheme = "https" if request.is_secure() else "http" + mp3_url = video.get_video_mp3().source_file.url + end_user_identifier = hashlib.sha256( + ( + AI_ENHANCEMENT_CLIENT_ID + + AI_ENHANCEMENT_CLIENT_SECRET + + request.user.username + ).encode("utf-8") + ).hexdigest() + creation_response = aristote.create_enhancement_from_url( + url_scheme + "://" + get_current_site(request).domain + mp3_url, + ["video/mp3"], + end_user_identifier + "@%s" % get_current_site(request).domain, + url_scheme + + "://" + + get_current_site(request).domain + + reverse("ai_enhancement:webhook"), + ) + if creation_response: + if creation_response["status"] == "OK": + AIEnhancement.objects.update_or_create( + video=video, + ai_enhancement_id_in_aristote=creation_response["id"], + ) + return redirect(reverse("video:video", args=[video.slug])) + else: + messages.add_message( + request, + messages.ERROR, + _("Something wrong... Status error: ") + + creation_response["status"], + ) + else: + messages.add_message( + request, messages.ERROR, _("Error: no response from Aristote AI.") + ) + else: + messages.add_message( + request, + messages.ERROR, + _("One or more errors have been found in the form."), + ) + else: + form = NotifyUserThirdPartyServicesForm() + return render( + request, + "create_enhancement.html", + { + "form": form, + "video": video, + "page_title": _("Enhance the video with Aristote AI"), + }, + ) + + +@csrf_protect +@ensure_csrf_cookie +@login_required(redirect_field_name="referrer") +def delete_enhance_video(request: WSGIRequest, video_slug: str) -> HttpResponse: + """The view to delete an enhancement.""" + if in_maintenance(): + return redirect(reverse("maintenance")) + video = get_object_or_404(Video, slug=video_slug) + if AI_ENHANCEMENT_TO_STAFF_ONLY and request.user.is_staff is False: + messages.add_message( + request, messages.ERROR, _("You cannot delete the video improvement.") + ) + raise PermissionDenied + if ( + video + and request.user != video.owner + and ( + not (request.user.is_superuser or request.user.has_perm("video.change_video")) + ) + and (request.user not in video.additional_owners.all()) + ): + messages.add_message( + request, messages.ERROR, _("You cannot delete the video improvement.") + ) + raise PermissionDenied + if enhancement_is_already_asked(video): + # AIEnhancement.objects.filter(video=video).first().delete() + enhancement = AIEnhancement.objects.filter(video=video).first() + if enhancement.is_ready: + aristote = AristoteAI(AI_ENHANCEMENT_CLIENT_ID, AI_ENHANCEMENT_CLIENT_SECRET) + return delete_enhancement_request(request, aristote, video) + messages.add_message(request, messages.ERROR, _("The video has not been improved.")) + return redirect(reverse("video:video", args=[video.slug])) + + +def delete_enhancement_request( + request: WSGIRequest, aristote: AristoteAI, video: Video +) -> HttpResponse: + """Send a request to delete an enhancement.""" + if request.method == "POST": + form = NotifyUserDeleteEnhancementForm(request.POST) + if form.is_valid(): + enhancement = AIEnhancement.objects.filter(video=video).first() + if enhancement: + deletion_response = aristote.delete_enhancement( + enhancement.ai_enhancement_id_in_aristote + ) + if deletion_response: + if deletion_response["status"] == "OK": + enhancement.delete() + messages.add_message( + request, + messages.SUCCESS, + _("Enhancement successfully deleted."), + ) + return redirect(reverse("video:video", args=[video.slug])) + else: + messages.add_message( + request, + messages.ERROR, + _("Something wrong... Status error: ") + + deletion_response["status"], + ) + else: + messages.add_message( + request, messages.ERROR, _("Error: no response from Aristote AI.") + ) + else: + messages.add_message(request, messages.ERROR, _("No enhancement found.")) + return redirect(reverse("video:video", args=[video.slug])) + else: + messages.add_message( + request, + messages.ERROR, + _("One or more errors have been found in the form."), + ) + else: + form = NotifyUserDeleteEnhancementForm() + return render( + request, + "delete_enhancement.html", + { + "form": form, + "video": video, + "page_title": _("Delete the video enhancement"), + }, + ) + + +@csrf_protect +@ensure_csrf_cookie +@login_required(redirect_field_name="referrer") +def enhance_video(request: WSGIRequest, video_slug: str) -> HttpResponse: + """The view to enrich a video.""" + if in_maintenance(): + return redirect(reverse("maintenance")) + video = get_object_or_404(Video, slug=video_slug) + + if AI_ENHANCEMENT_TO_STAFF_ONLY and request.user.is_staff is False: + messages.add_message( + request, messages.ERROR, _("You cannot use AI to improve this video.") + ) + raise PermissionDenied + + if ( + video + and request.user != video.owner + and ( + not (request.user.is_superuser or request.user.has_perm("video.change_video")) + ) + and (request.user not in video.additional_owners.all()) + ): + messages.add_message( + request, messages.ERROR, _("You cannot use AI to improve this video.") + ) + raise PermissionDenied + + if enhancement_is_already_asked(video): + enhancement = AIEnhancement.objects.filter(video=video).first() + if enhancement.is_ready: + return enhance_form(request, video) + else: + aristote = AristoteAI(AI_ENHANCEMENT_CLIENT_ID, AI_ENHANCEMENT_CLIENT_SECRET) + return send_enhancement_creation_request(request, aristote, video) + + +@csrf_protect +@ensure_csrf_cookie +@login_required(redirect_field_name="referrer") +def enhance_video_json(request: WSGIRequest, video_slug: str) -> HttpResponse: + """The view to get the JSON of Aristote version.""" + video = get_object_or_404(Video, slug=video_slug) + if AI_ENHANCEMENT_TO_STAFF_ONLY and request.user.is_staff is False: + messages.add_message( + request, messages.ERROR, _("You cannot use AI to improve this video.") + ) + raise PermissionDenied + + if ( + video + and request.user != video.owner + and ( + not (request.user.is_superuser or request.user.has_perm("video.change_video")) + ) + and (request.user not in video.additional_owners.all()) + ): + messages.add_message( + request, messages.ERROR, _("You cannot use AI to improve this video.") + ) + raise PermissionDenied + aristote = AristoteAI(AI_ENHANCEMENT_CLIENT_ID, AI_ENHANCEMENT_CLIENT_SECRET) + enhancement = AIEnhancement.objects.filter(video=video).first() + latest_version = aristote.get_latest_enhancement_version( + enhancement.ai_enhancement_id_in_aristote + ) + return JsonResponse(latest_version) + + +@csrf_protect +@ensure_csrf_cookie +@login_required(redirect_field_name="referrer") +def enhance_subtitles(request: WSGIRequest, video_slug: str) -> HttpResponse: + """The view to enrich the subtitles of a video.""" + video = get_object_or_404(Video, slug=video_slug, sites=get_current_site(request)) + if AI_ENHANCEMENT_TO_STAFF_ONLY and request.user.is_staff is False: + messages.add_message( + request, messages.ERROR, _("You cannot use AI to improve this video.") + ) + raise PermissionDenied + + if ( + video + and request.user != video.owner + and ( + not (request.user.is_superuser or request.user.has_perm("video.change_video")) + ) + and (request.user not in video.additional_owners.all()) + ): + messages.add_message( + request, messages.ERROR, _("You cannot use AI to improve this video.") + ) + raise PermissionDenied + if request.GET.get("src", None) is None: + aristote = AristoteAI(AI_ENHANCEMENT_CLIENT_ID, AI_ENHANCEMENT_CLIENT_SECRET) + enhancement = AIEnhancement.objects.filter(video=video).first() + latest_version = aristote.get_latest_enhancement_version( + enhancement.ai_enhancement_id_in_aristote + ) + web_vtt = json_to_web_vtt( + latest_version["transcript"]["sentences"], video.duration + ) + saveVTT(video, web_vtt, latest_version["transcript"]["language"]) + latest_track = ( + Track.objects.filter( + video=video, + ) + .order_by("id") + .first() + ) + return redirect( + reverse("ai_enhancement:enhance_subtitles", args=[video.slug]) + + "?src=" + + str(latest_track.src_id) + + "&generated=" + + str(True) + ) + + video_folder, created = UserFolder.objects.get_or_create( + name=video.slug, + owner=request.user, + ) + if enhancement_is_already_asked(video): + enhancement = AIEnhancement.objects.filter(video=video).first() + if enhancement.is_ready: + return render( + request, + "video_caption_maker.html", + { + "current_folder": video_folder, + "video": video, + "languages": LANG_CHOICES, + "page_title": _("Video Caption Maker - Aristote AI Version"), + # "ai_enhancement": enhancement, + }, + ) + # AIEnhancement.objects.filter(video=video).delete() + return redirect(reverse("video:video", args=[video.slug])) + + +@csrf_protect +@login_required(redirect_field_name="referrer") +def enhance_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: + """The view to enrich quiz of a video.""" + video = get_object_or_404(Video, slug=video_slug, sites=get_current_site(request)) + if AI_ENHANCEMENT_TO_STAFF_ONLY and request.user.is_staff is False: + messages.add_message( + request, messages.ERROR, _("You cannot use AI to improve this video.") + ) + raise PermissionDenied + + if ( + video + and request.user != video.owner + and ( + not (request.user.is_superuser or request.user.has_perm("video.change_video")) + ) + and (request.user not in video.additional_owners.all()) + ): + messages.add_message( + request, messages.ERROR, _("You cannot use AI to improve this video.") + ) + raise PermissionDenied + + aristote = AristoteAI(AI_ENHANCEMENT_CLIENT_ID, AI_ENHANCEMENT_CLIENT_SECRET) + enhancement = AIEnhancement.objects.filter(video=video).first() + if enhancement: + latest_version = aristote.get_latest_enhancement_version( + enhancement.ai_enhancement_id_in_aristote + ) + multiple_choice_questions_json = latest_version["multipleChoiceQuestions"] + + if enhancement_is_already_asked(video): + enhancement = AIEnhancement.objects.filter(video=video).first() + if enhancement.is_ready: + multiple_choice_questions_json = latest_version["multipleChoiceQuestions"] + + questions_dict = {"multiple_choice": multiple_choice_questions_json} + + import_quiz(video, json.dumps(questions_dict)) + + messages.add_message( + request, + messages.SUCCESS, + _("Quiz successfully imported."), + ) + return redirect( + reverse("quiz:edit_quiz", args=[video.slug]) + ) + + return redirect(reverse("video:video", args=[video.slug])) + + +def enhance_form(request: WSGIRequest, video: Video) -> HttpResponse: + """The view to choose the title of a video with the AI enhancement.""" + if request.method == "POST": + form = AIEnhancementChoice(request.POST, instance=video) + if form.is_valid(): + disciplines = video.discipline.all() + form.save() + discipline = Discipline.objects.filter( + title=form.cleaned_data["disciplines"] + ).first() + for dis in disciplines: + video.discipline.add(dis) + if discipline in Discipline.objects.all(): + video.discipline.add(discipline) + video.save() + if request.POST.get("_saveandback", None) is not None: + return redirect(reverse("video:video", args=[video.slug])) + return redirect( + reverse("ai_enhancement:enhance_subtitles", args=[video.slug]) + ) + else: + return render( + request, + "choose_video_element.html", + { + "video": video, + "form": form, + "page_title": _("Enhance the video with Aristote AI"), + }, + ) + else: + form = AIEnhancementChoice( + instance=video, + ) + return render( + request, + "choose_video_element.html", + { + "video": video, + "form": form, + "page_title": _("Enhance the video with Aristote AI"), + }, + ) + + +''' +def check_video_generated(request: WSGIRequest, video: Video) -> None: + """Check if the video is generated and delete the enhancement if it is.""" + if enhancement_is_already_asked(video) and request.GET.get("generated") == "True": + AIEnhancement.objects.filter(video=video).first().delete() +''' diff --git a/pod/completion/static/js/caption_maker.js b/pod/completion/static/js/caption_maker.js index cd31ee2d81..5c43eddff5 100644 --- a/pod/completion/static/js/caption_maker.js +++ b/pod/completion/static/js/caption_maker.js @@ -27,6 +27,22 @@ const file_prefix = window.location.pathname .match(/[\d\w-]+\/$/)[0] .replace("/", ""); + +/** + * Redirect to the video page. + * This function is called when the user validate the form and the subtitles is generated by AI Aristote. + */ +function redirectToTheVideo() { + showalert(gettext("You will soon be redirected."), "alert-warning"); + let pathParts = window.location.pathname.split('/'); + let slug = pathParts[pathParts.length - 2]; + setTimeout(function() { + window.location.href = '/video/' + slug + '/?generated=True'; + }, 2000); +} + + + document.addEventListener("click", (e) => { if (!e.target.parentNode) return; if ( @@ -88,6 +104,7 @@ document.addEventListener("DOMContentLoaded", function () { document.addEventListener("submit", (e) => { if (e.target.id != "form_save_captions") return; e.preventDefault(); + const params = new URLSearchParams(window.location.search); let caption_content = document.getElementById("captionContent"); if (!oldModeSelected) caption_content.value = generateWEBVTT(); @@ -111,6 +128,9 @@ document.addEventListener("submit", (e) => { document.querySelector('input[name="file_id"]').value = ""; send_form_save_captions(); } + if (params.get('generated') === 'True') { + redirectToTheVideo(); + } }); document.addEventListener("click", (evt) => { @@ -136,6 +156,9 @@ document.addEventListener("click", (evt) => { //form_save_captions.querySelector('input[name="enrich_ready"]').value=""; send_form_save_captions(); + if (params.get('generated') === 'True') { + redirectToTheVideo(); + } } }); @@ -557,7 +580,8 @@ function validateForms(forms) { // After Browser checks, we add some custom ones let captionInput = e.querySelector(".captionTextInput"); - if (captionInput.value.length > 80) { + // 81 for carriage return + if (captionInput.value.length > 81) { captionInput.setCustomValidity( gettext("A caption cannot contain more than 80 characters.") + "[" + diff --git a/pod/completion/templates/video_caption_maker.html b/pod/completion/templates/video_caption_maker.html index 6cf12047ba..0de512d81b 100644 --- a/pod/completion/templates/video_caption_maker.html +++ b/pod/completion/templates/video_caption_maker.html @@ -24,6 +24,12 @@ {% block page_content %}
+ {% if ai_enrichment %} + + {% endif %} +
@@ -107,7 +113,7 @@
-

{% trans "No captions" %}

+

{% trans "No captions" %}

- +  {% trans 'Back to my playlists' %}
diff --git a/pod/quiz/forms.py b/pod/quiz/forms.py index 815419790d..121a4a1b76 100644 --- a/pod/quiz/forms.py +++ b/pod/quiz/forms.py @@ -76,7 +76,10 @@ class QuestionForm(forms.Form): required=True, help_text=_("Please choose the question type."), ) - + question_id = forms.IntegerField( + widget=forms.HiddenInput(), + required=False, + ) single_choice = forms.CharField( widget=forms.HiddenInput(attrs={"class": "hidden-single-choice-field"}), required=False, @@ -188,7 +191,7 @@ class SingleChoiceQuestionForm(forms.ModelForm): selected_choice = forms.CharField( label=_("Single choice question"), widget=forms.RadioSelect(), - required=False, + required=True, help_text=_("Please choose one answer."), ) @@ -207,10 +210,13 @@ def __init__(self, *args, **kwargs): choices_dict = {} choices_list = [(choice, choice) for choice in choices_dict.keys()] - self.fields["selected_choice"].widget.choices = choices_list self.fields["selected_choice"].widget.attrs["class"] = "list-unstyled ps-2" + def clean_selected_choice(self): + data = self.cleaned_data["selected_choice"] + return data + class MultipleChoiceQuestionForm(forms.ModelForm): """Form to add or edit a multiple choice question form.""" @@ -218,7 +224,7 @@ class MultipleChoiceQuestionForm(forms.ModelForm): selected_choice = forms.CharField( label=_("Multiple choice question"), widget=forms.CheckboxSelectMultiple(), - required=False, + required=True, help_text=_("Please check any answers you want."), ) @@ -241,6 +247,10 @@ def __init__(self, *args, **kwargs): self.fields["selected_choice"].widget.choices = choices_list self.fields["selected_choice"].widget.attrs["class"] = "list-unstyled ps-2" + def clean_selected_choice(self): + data = self.cleaned_data["selected_choice"] + return data + class ShortAnswerQuestionForm(forms.ModelForm): """Form to add or edit a short answer question form.""" diff --git a/pod/quiz/static/quiz/js/create-quiz.js b/pod/quiz/static/quiz/js/create-quiz.js index ff77355aec..a5d4b27e7d 100644 --- a/pod/quiz/static/quiz/js/create-quiz.js +++ b/pod/quiz/static/quiz/js/create-quiz.js @@ -21,6 +21,10 @@ document.addEventListener("DOMContentLoaded", function () { addEventListenerQuestionType(questionTypeEl); } + for (let deleteInput of document.querySelectorAll('label[for*="-DELETE"]')) { + deleteInput.parentElement.classList.add('d-none'); + } + /** * Retrieves question data from the initial data based on the provided question form. * @param {HTMLElement} questionForm - The HTML form element representing a question. @@ -144,10 +148,17 @@ document.addEventListener("DOMContentLoaded", function () { const questionFormToDelete = event.target.closest(".question-form"); if (questionFormToDelete) { - questionFormToDelete.remove(); - - const currentQuestionForms = document.querySelectorAll(".question-form"); - totalNewForms.setAttribute("value", currentQuestionForms.length - 1); + + const deleteInput = document.getElementById(`id_questions-${questionFormToDelete.getAttribute("data-question-index")}-DELETE`); + if (deleteInput) { + deleteInput.checked = true; + questionFormToDelete.classList.add('d-none'); + } else { + questionFormToDelete.remove(); + const currentQuestionForms = document.querySelectorAll(".question-form"); + totalNewForms.setAttribute("value", currentQuestionForms.length - 1); + } + } } @@ -273,6 +284,7 @@ document.addEventListener("DOMContentLoaded", function () { const fieldset = document.createElement("fieldset"); const legend = document.createElement("legend"); + legend.classList.add("col-form-label"); legend.textContent = gettext("Your choices"); fieldset.appendChild(legend); @@ -331,6 +343,7 @@ document.addEventListener("DOMContentLoaded", function () { "btn", "btn-link", "pod-btn-social", + "py-0", ); deleteButton.addEventListener("click", function () { choiceDiv.remove(); @@ -344,7 +357,6 @@ document.addEventListener("DOMContentLoaded", function () { textInput.classList.add("form-control", "ms-2"); if (choice) { textInput.value = choice[0]; - console.log(input); if (choice[1]) { input.checked = true; } @@ -371,6 +383,7 @@ document.addEventListener("DOMContentLoaded", function () { const fieldset = document.createElement("fieldset"); const legend = document.createElement("legend"); + legend.classList.add("col-form-label"); legend.textContent = gettext("Your choices"); fieldset.appendChild(legend); @@ -466,6 +479,8 @@ document.addEventListener("DOMContentLoaded", function () { "btn", "btn-outline-secondary", "btn-sm", + "ms-2", + "mb-2", ); buttonElement.textContent = gettext("Get time from the player"); @@ -497,7 +512,6 @@ document.addEventListener("DOMContentLoaded", function () { let questionFormsList = document.querySelectorAll(".question-form"); for (questionForm of questionFormsList) { - console.log(questionForm); const questionType = questionForm.querySelector( ".question-select-type", ).value; @@ -520,6 +534,7 @@ document.addEventListener("DOMContentLoaded", function () { } } let form = document.getElementById("quiz-form"); + console.log(form); form.submit(); }); } diff --git a/pod/quiz/static/quiz/js/video-quiz-submit.js b/pod/quiz/static/quiz/js/video-quiz-submit.js index 44346a9806..39630edd6f 100644 --- a/pod/quiz/static/quiz/js/video-quiz-submit.js +++ b/pod/quiz/static/quiz/js/video-quiz-submit.js @@ -4,8 +4,32 @@ for (questionElement of questionList) { let showResponseButton = questionElement.querySelector( ".show-response-button", ); - showResponseButton.addEventListener("click", function (event) { - event.preventDefault(); - player.currentTime(this.attributes.start.value); - }); + if(showResponseButton) { + showResponseButton.addEventListener("click", function (event) { + event.preventDefault(); + if(player.paused()) { + player.play() + } + player.currentTime(this.attributes.start.value); + }); + } + + // get all answer and parse it + // if answer in good answer, put it in green else if user answer put it in red + let questionid = questionElement.dataset.questionid; + let allanswers = questionElement.querySelectorAll(`ul#id_${questionid}-selected_choice li input`); + for (answer of allanswers) { + if (questions_answers[`${questionid}`]) { + let user_answer = questions_answers[`${questionid}`][0]; + let correct_answer = questions_answers[`${questionid}`][1]; + if( (Array.isArray(correct_answer) && correct_answer.includes(answer.value)) || correct_answer === answer.value ){ + answer.closest('li').classList.add('alert', 'alert-success'); + } else if ((Array.isArray(user_answer) && user_answer.includes(answer.value)) || user_answer === answer.value ){ + answer.closest('li').classList.add('alert', 'alert-danger'); + } + if ((Array.isArray(user_answer) && user_answer.includes(answer.value)) || user_answer === answer.value ){ + answer.checked = true; + } + } + } } diff --git a/pod/quiz/templates/quiz/create_edit_quiz.html b/pod/quiz/templates/quiz/create_edit_quiz.html index 42c4ddd2d5..0cfe3b299a 100644 --- a/pod/quiz/templates/quiz/create_edit_quiz.html +++ b/pod/quiz/templates/quiz/create_edit_quiz.html @@ -15,15 +15,12 @@ {% endblock %} {% block page_content %} - - {% include 'videos/video-element.html' %} -
{% csrf_token %} @@ -42,7 +39,7 @@ {{ field }}
{% else %} - + {{ field }} {% endif %} {% if field.help_text %} @@ -54,7 +51,7 @@ {% endfor %} {{ question_formset.management_form }} -
+
{% for question_form in question_formset %} {% with form_index=forloop.counter0 %} {% include "quiz/question_form.html" with form=question_form form_index=form_index %} @@ -84,6 +81,11 @@

{% endif %} +
+

 {% trans "Video"%}

+ {% include 'videos/video-element.html' %} +
+ {% include "main/mandatory_fields.html" %} {% endblock page_aside %} diff --git a/pod/quiz/templates/quiz/delete_quiz.html b/pod/quiz/templates/quiz/delete_quiz.html index 9295da6375..e78c497fb8 100644 --- a/pod/quiz/templates/quiz/delete_quiz.html +++ b/pod/quiz/templates/quiz/delete_quiz.html @@ -10,7 +10,7 @@ {% endblock %} diff --git a/pod/quiz/templates/quiz/question_form.html b/pod/quiz/templates/quiz/question_form.html index b5058333b0..58e178ea93 100644 --- a/pod/quiz/templates/quiz/question_form.html +++ b/pod/quiz/templates/quiz/question_form.html @@ -1,6 +1,6 @@ {% load i18n %} -
+
  @@ -30,7 +30,7 @@ {{ field }}
{% else %} - + {{ field }} {% endif %} {% if field.help_text %} diff --git a/pod/quiz/templates/quiz/video_quiz.html b/pod/quiz/templates/quiz/video_quiz.html index 2306f9b772..8036542044 100644 --- a/pod/quiz/templates/quiz/video_quiz.html +++ b/pod/quiz/templates/quiz/video_quiz.html @@ -1,6 +1,8 @@ {% extends 'base.html' %} {% load i18n %} {% load static %} +{% load filters %} +{% load video_quiz %} {% block page_extra_head %} {% include 'videos/video-header.html' %} @@ -14,13 +16,13 @@ {% endblock %} {% block page_content %} - {% if form_submitted %} + {% if form_submitted and questions_form_errors.items|length == 0 %} {% if percentage_score >= 75 %} {% endif %} + {% elif questions_form_errors %} + {% endif %} - - {% include 'videos/video-element.html' %} -
{% csrf_token %} - - {% for question in quiz.get_questions %} -
- -  {{ question.title }} - - {% with form=question.get_question_form %} - {% for field in form.visible_fields %} - {% spaceless %} -
-
- {{ field.errors }} - {% if "form-check-input" in field.field.widget.attrs.class %} -
- {{ field }} -
- {% else %} - - {{ field }} - {% endif %} - {% if field.help_text %} - {{ field.help_text|safe }} - {% endif %} - {% if field.field.required %} -
{% trans "Please provide a valid value for this field." %}
- {% endif %} +
+ {% for question in quiz.get_questions %} +
- {% endspaceless %} - {% endfor %} - {% endwith %} + {% endspaceless %} + {% endfor %} + {% endwith %} - {% if form_submitted and quiz.show_correct_answers %} -

{% trans "Right answer:" %} {{ question.get_answer }}

- {% if question.explanation %} -

{% trans "Explanation:" %} {{ question.explanation }}

- {% endif %} - {% if question.start_timestamp %} - - {% trans "Show answer in the video" %} - + {% if form_submitted and quiz.show_correct_answers %} + {% endif %} - {% endif %} -
- {% endfor %} - +
+ {% endfor %} +
- + {% if form_submitted %} + + {% trans "Redo the quiz" %} + + {% else %} + + {% endif %} {% trans "Back to video" %}
@@ -111,6 +124,10 @@

{% endif %} +
+

 {% trans "Video"%}

+ {% include 'videos/video-element.html' %} +
{% endblock page_aside %} {% block more_script %} @@ -120,6 +137,10 @@

{% include "videos/video-script.html" %} {% if form_submitted %} + {{ questions_answers|json_script:"questions_answers" }} + {% endif %} {% endblock more_script %} diff --git a/pod/quiz/templates/quiz/video_quiz_aside.html b/pod/quiz/templates/quiz/video_quiz_aside.html new file mode 100644 index 0000000000..e101954ae4 --- /dev/null +++ b/pod/quiz/templates/quiz/video_quiz_aside.html @@ -0,0 +1,36 @@ +{% load i18n %} +{% load video_quiz %} + + +

+  {% trans "Quiz"%} +

+ +
+ {% is_quiz_accessible video as is_quiz_accessible %} + {% if is_quiz_accessible %} + + + {% trans 'Answer the quiz' %} + + {% endif %} + + {% is_quiz_exists video as is_quiz_exists %} + {% if video.owner == request.user or request.user in video.additional_owners.all %} + {% if is_quiz_exists %} + + + {% trans 'Edit your quiz' %} + + + + {% trans 'Delete your quiz' %} + + {% else %} + + + {% trans 'Create a quiz' %} + + {% endif %} + {% endif %} +
diff --git a/pod/quiz/templatetags/video_quiz.py b/pod/quiz/templatetags/video_quiz.py index cc51cfba61..baefd79c3e 100644 --- a/pod/quiz/templatetags/video_quiz.py +++ b/pod/quiz/templatetags/video_quiz.py @@ -46,3 +46,25 @@ def is_quiz_exists(video: Video) -> bool: if get_video_quiz(video): return True return False + + +@register.simple_tag(name="get_question_color") +def get_question_color(is_submitted: bool, score: int = None) -> str: + """ + Template tag used to return a color corresponding to the score. + + Args: + is_submitted (bool): True if form is submitted. + score (int): A question score (from 0 to 1) + + + Returns: + str: The corresponding bootstrap color. + """ + if is_submitted and score: + if score <= 0.5: + return "danger" + elif score <= 0.75: + return "warning" + return "success" + return "dark" diff --git a/pod/quiz/tests/test_views.py b/pod/quiz/tests/test_views.py index fa3112e6fb..ba97bd3fdf 100644 --- a/pod/quiz/tests/test_views.py +++ b/pod/quiz/tests/test_views.py @@ -90,7 +90,7 @@ def test_get_request_for_create_quiz_view(self): self.assertIn("quiz_form", response.context) self.assertIsInstance(response.context["quiz_form"], QuizForm) self.assertIn("question_formset", response.context) - self.assertEqual(response.context["question_formset"].extra, 2) + self.assertEqual(response.context["question_formset"].extra, 1) self.assertEqual(response.context["question_formset"].prefix, "questions") print(" ---> test_get_request_for_create_quiz_view ok") diff --git a/pod/quiz/utils.py b/pod/quiz/utils.py index dad1c89ec2..09a4c965b8 100644 --- a/pod/quiz/utils.py +++ b/pod/quiz/utils.py @@ -11,6 +11,9 @@ from itertools import chain from pod.video.models import Video +from pod.video_encode_transcript.utils import time_to_seconds + +import json def get_quiz_questions( @@ -51,3 +54,53 @@ def get_video_quiz(video: Video) -> Optional[Quiz]: Optional[Quiz]: The quiz associated with the video, or None if no quiz is found. """ return Quiz.objects.filter(video=video).first() + + +def create_question_from_aristote_json( + quiz: Quiz, question_type: str, question_dict: dict +) -> None: + """ + Creates and associates questions with a given quiz based on JSON data. + + Args: + quiz (Quiz): The quiz instance. + questions_data (list): List of question data dictionaries. + """ + title = question_dict["question"] + explanation = question_dict["explanation"] + start_timestamp = question_dict["answerPointer"]["startAnswerPointer"] + end_timestamp = question_dict["answerPointer"]["stopAnswerPointer"] + + if question_type == "multiple_choice": + question_choices = { + i["optionText"]: i["correctAnswer"] for i in question_dict["choices"] + } + MultipleChoiceQuestion.objects.create( + quiz=quiz, + title=title, + explanation=explanation, + start_timestamp=time_to_seconds(start_timestamp), + end_timestamp=time_to_seconds(end_timestamp), + choices=json.dumps(question_choices), + ) + + +def import_quiz(video: Video, quiz_data_json: str): + """ + function to import a quiz from a JSON input for a given video. + + Args: + video (Video): The video for which to associate quiz. + quiz_data_json (str): JSON string containing quiz data. + """ + existing_quiz = get_video_quiz(video) + if existing_quiz: + existing_quiz.delete() + quiz_data = json.loads(quiz_data_json) + + new_quiz = Quiz.objects.create(video=video) + for question_type, question_list in quiz_data.items(): + for question in question_list: + create_question_from_aristote_json( + quiz=new_quiz, question_type=question_type, question_dict=question + ) diff --git a/pod/quiz/views.py b/pod/quiz/views.py index beb3b124a2..e24a75713c 100644 --- a/pod/quiz/views.py +++ b/pod/quiz/views.py @@ -4,7 +4,7 @@ import json from typing import Optional from django.forms import formset_factory -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -52,7 +52,7 @@ def create_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: if get_video_quiz(video): return redirect(reverse("quiz:edit_quiz", kwargs={"video_slug": video.slug})) - question_formset_factory = formset_factory(QuestionForm, extra=2) + question_formset_factory = formset_factory(QuestionForm, extra=1) if not ( request.user.is_superuser or request.user.is_staff or request.user == video.owner ): @@ -62,8 +62,12 @@ def create_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: raise PermissionDenied if request.method == "POST": + question_formset = question_formset_factory( + request.POST, + prefix="questions", + ) return handle_post_request_for_create_or_edit_quiz( - request, video, question_formset_factory, action="create" + request, video, question_formset, action="create" ) else: quiz_form = QuizForm() @@ -89,34 +93,45 @@ def update_questions(existing_quiz: Quiz, question_formset) -> None: existing_quiz (Quiz): The existing quiz instance. question_formset: The formset containing updated question data. """ - for question_form, existing_question in zip( - question_formset, existing_quiz.get_questions() - ): + for question_form in question_formset: question_type = question_form.cleaned_data.get("type") + question_id = question_form.cleaned_data.get("question_id") + if question_id is None and question_form.cleaned_data.get("DELETE"): + continue # to prevent empty question not filled title = question_form.cleaned_data["title"] explanation = question_form.cleaned_data["explanation"] start_timestamp = question_form.cleaned_data["start_timestamp"] end_timestamp = question_form.cleaned_data["end_timestamp"] - - if question_type == "short_answer": - existing_question.answer = question_form.cleaned_data["short_answer"] - elif question_type == "long_answer": - existing_question.answer = question_form.cleaned_data["long_answer"] - elif question_type == "single_choice": - existing_question.choices = question_form.cleaned_data["single_choice"] - elif question_type == "multiple_choice": - existing_question.choices = question_form.cleaned_data["multiple_choice"] - - existing_question.title = title - existing_question.explanation = explanation - existing_question.start_timestamp = start_timestamp - existing_question.end_timestamp = end_timestamp - - existing_question.save() + if question_id: + existing_question = get_question(question_type, question_id, existing_quiz) + if not existing_question: + continue + if question_form.cleaned_data.get("DELETE"): + existing_question.delete() + else: + existing_question.title = title + existing_question.explanation = explanation + existing_question.start_timestamp = start_timestamp + existing_question.end_timestamp = end_timestamp + if question_type in {"short_answer", "long_answer"}: + existing_question.answer = question_form.cleaned_data[question_type] + elif question_type in {"single_choice", "multiple_choice"}: + existing_question.choices = question_form.cleaned_data[question_type] + existing_question.save() + else: + create_question( + question_type=question_type, + quiz=existing_quiz, + title=title, + explanation=explanation, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + question_data=question_form.cleaned_data[question_type], + ) def handle_post_request_for_create_or_edit_quiz( - request: WSGIRequest, video: Video, question_formset_factory, action: str + request: WSGIRequest, video: Video, question_formset, action: str ) -> HttpResponse: """ Handles the POST request for creating or editing a quiz associated with a video. @@ -124,14 +139,13 @@ def handle_post_request_for_create_or_edit_quiz( Args: request (WSGIRequest): The HTTP request. video (Video): The associated video instance. - question_formset_factory: The formset factory for handling question forms. + question_formset: The formset factory for handling question forms. action (str): The action to perform - "create" or "edit". Returns: HttpResponse: The HTTP response for rendering the appropriate template. """ quiz_form = QuizForm(request.POST) - question_formset = question_formset_factory(request.POST, prefix="questions") if quiz_form.is_valid() and question_formset.is_valid(): if action == "create": new_quiz = create_or_edit_quiz_instance(video, quiz_form, action) @@ -149,8 +163,10 @@ def handle_post_request_for_create_or_edit_quiz( messages.SUCCESS, _("Quiz successfully updated."), ) - - return HttpResponseRedirect(reverse("video:video", kwargs={"slug": video.slug})) + return redirect( + reverse("quiz:edit_quiz", args=[video.slug]) + ) + # return HttpResponseRedirect(reverse("video:video", kwargs={"slug": video.slug})) else: messages.add_message( request, @@ -170,6 +186,46 @@ def handle_post_request_for_create_or_edit_quiz( ) +def get_question( + question_type: str, + question_id: int, + quiz: Quiz +): + """ + Returns the question found according to its type, identifier and the quiz to which it belongs. + + Args: + question_type (str): The type fo the question. + question_id (int): The identifier of the question. + quiz (Quiz): The quiz object. + + Returns: + question: The question if found else None. + """ + question = None + if question_type == "short_answer": + question = ShortAnswerQuestion.objects.filter( + quiz=quiz, + id=question_id, + ).first() + elif question_type == "long_answer": + question = LongAnswerQuestion.objects.filter( + quiz=quiz, + id=question_id, + ).first() + elif question_type == "single_choice": + question = SingleChoiceQuestion.objects.filter( + quiz=quiz, + id=question_id, + ).first() + elif question_type == "multiple_choice": + question = MultipleChoiceQuestion.objects.filter( + quiz=quiz, + id=question_id, + ).first() + return question + + def create_or_edit_quiz_instance( video: Video, quiz_form: QuizForm, action: str ) -> Optional[Quiz]: @@ -200,6 +256,53 @@ def create_or_edit_quiz_instance( return existing_quiz +def create_question( + question_type: str, + quiz: Quiz, + title: str, + explanation: str, + start_timestamp: int, + end_timestamp: int, + question_data: str, +): + if question_type == "short_answer": + ShortAnswerQuestion.objects.get_or_create( + quiz=quiz, + title=title, + explanation=explanation, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + answer=question_data, + ) + elif question_type == "long_answer": + LongAnswerQuestion.objects.get_or_create( + quiz=quiz, + title=title, + explanation=explanation, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + answer=question_data, + ) + elif question_type == "single_choice": + SingleChoiceQuestion.objects.get_or_create( + quiz=quiz, + title=title, + explanation=explanation, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + choices=question_data, + ) + elif question_type == "multiple_choice": + MultipleChoiceQuestion.objects.get_or_create( + quiz=quiz, + title=title, + explanation=explanation, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + choices=question_data, + ) + + def create_questions(new_quiz: Quiz, question_formset) -> None: """ Creates and associates questions with a given quiz based on the provided formset. @@ -215,42 +318,15 @@ def create_questions(new_quiz: Quiz, question_formset) -> None: start_timestamp = question_form.cleaned_data["start_timestamp"] end_timestamp = question_form.cleaned_data["end_timestamp"] - if question_type == "short_answer": - ShortAnswerQuestion.objects.get_or_create( - quiz=new_quiz, - title=title, - explanation=explanation, - start_timestamp=start_timestamp, - end_timestamp=end_timestamp, - answer=question_form.cleaned_data["short_answer"], - ) - elif question_type == "long_answer": - LongAnswerQuestion.objects.get_or_create( - quiz=new_quiz, - title=title, - explanation=explanation, - start_timestamp=start_timestamp, - end_timestamp=end_timestamp, - answer=question_form.cleaned_data["long_answer"], - ) - elif question_type == "single_choice": - SingleChoiceQuestion.objects.get_or_create( - quiz=new_quiz, - title=title, - explanation=explanation, - start_timestamp=start_timestamp, - end_timestamp=end_timestamp, - choices=question_form.cleaned_data["single_choice"], - ) - elif question_type == "multiple_choice": - MultipleChoiceQuestion.objects.get_or_create( - quiz=new_quiz, - title=title, - explanation=explanation, - start_timestamp=start_timestamp, - end_timestamp=end_timestamp, - choices=question_form.cleaned_data["multiple_choice"], - ) + create_question( + question_type=question_type, + quiz=new_quiz, + title=title, + explanation=explanation, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + question_data=question_form.cleaned_data[question_type], + ) def calculate_score(question: Question, form) -> float: @@ -273,12 +349,17 @@ def calculate_score(question: Question, form) -> float: elif question.get_type() == "multiple_choice": user_answer = form.cleaned_data.get("selected_choice") correct_answer = question.get_answer() - user_answer = ast.literal_eval( - user_answer - ) # Cannot use JSON.loads in case of quotes in a user answer. - intersection = set(user_answer) & set(correct_answer) - score = len(intersection) / len(correct_answer) - return score + if user_answer != "": + user_answer = ast.literal_eval( + user_answer + ) # Cannot use JSON.loads in case of quotes in a user answer. + intersection = set(user_answer) & set(correct_answer) + score = len(intersection) / len(correct_answer) + + len_incorrect = len(user_answer) - len(intersection) + penalty = len_incorrect / len(correct_answer) + score = max(0, score - penalty) + return score elif question.get_type() in {"short_answer", "long_answer"}: user_answer = form.cleaned_data.get("user_answer") @@ -286,7 +367,7 @@ def calculate_score(question: Question, form) -> float: # Add similar logic for other question types... - if user_answer is not None and correct_answer is not None: + if (user_answer is not None and user_answer != "") and correct_answer is not None: return 1.0 if user_answer.lower() == correct_answer.lower() else 0.0 return 0.0 @@ -305,14 +386,25 @@ def process_quiz_submission(request: WSGIRequest, quiz: Quiz) -> float: """ total_questions = len(quiz.get_questions()) score = 0.0 - + questions_stats = {} + questions_answers = {} + questions_form_errors = {} for question in quiz.get_questions(): form = question.get_question_form(request.POST) if form.is_valid(): - score += calculate_score(question, form) - + question_score = calculate_score(question, form) + score += question_score + questions_stats[question.id] = question_score + if question.get_type() in ["single_choice", "multiple_choice"]: + user_answer = form.cleaned_data["selected_choice"] + if question.get_type() == "multiple_choice": + user_answer = ast.literal_eval(user_answer) + correct_answer = question.get_answer() + questions_answers["question_%s" % question.id] = [user_answer, correct_answer] + else: + questions_form_errors[question.title] = _('You have to choose at least one answer') percentage_score = (score / total_questions) * 100 - return percentage_score + return percentage_score, questions_stats, questions_answers, questions_form_errors def video_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: @@ -333,12 +425,14 @@ def video_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: quiz = get_video_quiz(video) form_submitted = False percentage_score = None - + questions_stats = {} + questions_answers = {} + questions_form_errors = {} if quiz.connected_user_only and not request.user.is_authenticated: return redirect("%s?referrer=%s" % (settings.LOGIN_URL, request.get_full_path())) if request.method == "POST": - percentage_score = process_quiz_submission(request, quiz) + (percentage_score, questions_stats, questions_answers, questions_form_errors) = process_quiz_submission(request, quiz) form_submitted = True return render( @@ -350,6 +444,9 @@ def video_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: "quiz": quiz, "form_submitted": form_submitted, "percentage_score": percentage_score, + "questions_stats": questions_stats, + "questions_answers": questions_answers, + "questions_form_errors": questions_form_errors, }, ) @@ -428,8 +525,6 @@ def edit_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: return redirect(reverse("maintenance")) video = get_object_or_404(Video, slug=video_slug) - question_formset_factory = formset_factory(QuestionForm, extra=0) - if not ( request.user.is_superuser or request.user.is_staff or request.user == video.owner ): @@ -437,10 +532,27 @@ def edit_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: raise PermissionDenied quiz = get_object_or_404(Quiz, video=video) - + existing_questions = quiz.get_questions() + extra = 2 if existing_questions == [] else 0 + question_formset_factory = formset_factory(QuestionForm, extra=extra, can_delete=True) if request.method == "POST": + question_formset = question_formset_factory( + request.POST, + prefix="questions", + initial=[ + { + "type": question.get_type(), + "title": question.title, + "explanation": question.explanation, + "start_timestamp": question.start_timestamp, + "end_timestamp": question.end_timestamp, + "question_id": question.id + } + for question in existing_questions + ], + ) return handle_post_request_for_create_or_edit_quiz( - request, video, question_formset_factory, action="edit" + request, video, question_formset, action="edit" ) else: quiz_form = QuizForm( @@ -449,9 +561,9 @@ def edit_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: "show_correct_answers": quiz.show_correct_answers, } ) - - existing_questions = quiz.get_questions() - initial_data = get_initial_data(existing_questions=existing_questions) + initial_data = None + if existing_questions != []: + initial_data = get_initial_data(existing_questions=existing_questions) question_formset = question_formset_factory( prefix="questions", @@ -462,6 +574,7 @@ def edit_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: "explanation": question.explanation, "start_timestamp": question.start_timestamp, "end_timestamp": question.end_timestamp, + "question_id": question.id } for question in existing_questions ], @@ -490,6 +603,7 @@ def get_initial_data(existing_questions=None) -> str: Returns: str: JSON-encoded initial data for JavaScript fields. """ + initial_data = {} if existing_questions: initial_data = { "existing_questions": [ diff --git a/pod/settings.py b/pod/settings.py index cbf9b641e5..8fbbb76a1a 100644 --- a/pod/settings.py +++ b/pod/settings.py @@ -70,6 +70,7 @@ "pod.import_video", "pod.progressive_web_app", "pod.dressing", + "pod.ai_enhancement", "pod.custom", ] @@ -127,6 +128,7 @@ "pod.recorder.context_processors.context_recorder_settings", "pod.playlist.context_processors.context_settings", "pod.quiz.context_processors.context_settings", + "pod.ai_enhancement.context_processors.context_settings", "pod.dressing.context_processors.context_settings", "pod.import_video.context_processors.context_settings", "pod.cut.context_processors.context_settings", diff --git a/pod/urls.py b/pod/urls.py index 6587e8bbd3..f33e8b936c 100644 --- a/pod/urls.py +++ b/pod/urls.py @@ -38,6 +38,7 @@ USE_DRESSING = getattr(settings, "USE_DRESSING", True) USE_IMPORT_VIDEO = getattr(settings, "USE_IMPORT_VIDEO", True) USE_QUIZ = getattr(settings, "USE_QUIZ", True) +USE_AI_ENHANCEMENT = getattr(settings, "USE_AI_ENHANCEMENT", False) if USE_CAS: from cas import views as cas_views @@ -169,6 +170,12 @@ path("playlist/", include("pod.playlist.urls", namespace="playlist")), ] +# AI ENHANCEMENT +if USE_AI_ENHANCEMENT: + urlpatterns += [ + path("ai-enhancement/", include("pod.ai_enhancement.urls", namespace="ai_enhancement")), + ] + # QUIZ if USE_QUIZ: urlpatterns += [ diff --git a/pod/video/templates/videos/link_video.html b/pod/video/templates/videos/link_video.html index 608793f456..67275d9e43 100644 --- a/pod/video/templates/videos/link_video.html +++ b/pod/video/templates/videos/link_video.html @@ -1,6 +1,7 @@ {% load i18n %} {% load favorites_playlist %} {% load playlist_buttons %} +{% load ai_enhancement_template_tags %} {% spaceless %} {% if request.path == '/video/dashboard/' %} @@ -32,8 +33,16 @@ {% endif %} {% if video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all or perms.video.change_video %} - - + + + + {% user_can_enhance_video video as can_enhance_video_with_ai %} + {% enhancement_is_already_asked video as enr_is_already_asked %} + {% if can_enhance_video_with_ai and not enr_is_already_asked %} + + + + {% endif %} {% endif %} {% comment %} {% if request.resolver_match.namespace %} diff --git a/pod/video/templates/videos/video-info.html b/pod/video/templates/videos/video-info.html index a360ef5408..f38d30a0ab 100644 --- a/pod/video/templates/videos/video-info.html +++ b/pod/video/templates/videos/video-info.html @@ -5,7 +5,6 @@ {% load video_tags %} {% load playlist_stats %} {% load favorites_playlist %} -{% load video_quiz %}
@@ -42,12 +41,6 @@
{% endif %}
- {% is_quiz_accessible video as is_quiz_accessible %} - {% if USE_QUIZ and is_quiz_accessible %} - - - - {% endif %} {% if USE_PLAYLIST and user.is_authenticated and not video.is_draft %}