From 7b5427f6d6026bbb33cbf6bce1f6a7e309d60eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Mon, 19 Apr 2021 14:35:13 +0000 Subject: [PATCH] [REF] website_slides: refactor external content management in slide.slide PURPOSE Globally refactor the way we handle external content (YouTube videos, Google Drive documents, ...) in the course slides. This includes both technical changes (new computed fields, rename, ...) as well as visual changes on the form views that allow introducing new content, both in the backend (slide.slide form view) and in the frontend (course main website page). SPECS TECHNICAL REFACTOR 1. Rework the "url" field This field was used without any distinction for videos, regular documents and images, leading to a big confusion both technically and visually. The urls are now split in separate fields (video_url, document_google_url and image_google_url), based on the type of slide. Those fields are implemented as 'related' to the stored 'url' field to avoid extra storage. This change globally increases the readability and usability in the code, and also allows for clear labels on the various views. 1b. Rework the "binary_content" field The same way as for the 'url' field, the 'binary_content' field has been split into 2 related fields ('document_binary_content' and 'image_binary_content'). This allows for more readability and also helps in the form view to limit the file selection to the supported file types. 2. Remove the "document_id" stored field This field did not have to be stored since it can "easily" be inferred from the video/document URL with some regex work. 3. Remove the "mime_type" field This field's value was only determined by an external call to the Google Drive API. It was inconsistent because not set for manually uploaded document and was used for some strange checks that seemed to work but did not make much sense. (e.g: used to make the difference between a Google Drive video and a YouTube video??). It has been replaced by a "document_type" field, which is always 'pdf' for local files (only type we support) and inferred from the mimeType returned by Google Drive metadata for external documents. This allows, for instance, to show pretty icons next to every slide based on their type, "sheet" files (Excel, Google Sheet, ...) share the same icons, the same principle is applied to "doc" files (Word, OpenOffice, Google Doc, ...) and finally "slides" files (PowerPoint, Google Slides, ...). 4. Introduce several computed fields Such as: - video_source_type - youtube_id - google_drive_id These fields are mostly inferred from the slide type and the related URL. They are used to make clear checks in the code and ease readability, for example when constructing the slide "embed code" to insert it in the frontend full screen viewer. 5. Remove the "presentation" slide_type The slide type "presentation" was some kind of very confusing type between image and pdf. It was mostly relying on the fact that the content had a bigger width than height ('landscape' display). This lead to a lot of confusion both in the code base and in the various forms allowing to introduce new content ("what should I choose? document or presentation?"). In order to simplify everything, we completely removed the slide type "presentation" and converted all the data that had this type and the various code checks to the type "document" instead. Meaning that we end up with the following slide types: - Infographic - for images, local files + external links - Web Page - local input only - Document local files + external links - Video - external only, YouTube or Google Drive links - Quiz FUNCTIONAL Following the technical changes here above, we can improve the form view of the slide.slide model as well as the website form that allows adding new content. 1. Slide form view To make it clear where the source of the document comes from, we introduced a "source_type" field that is either "local_file", used when uploading a file from your computer, or "external", used when linking content from Google Drive. The source_type selection appears when adding content of type "document" or "infographic" and conditions the display of the file upload button or the document_url field. 2. Adding content from the website When adding content from the website, the user is now invited to select from ALL the available slide type, meaning we added "Infographic" to the selection. Before this change, if you wanted to link an image, you had to select the type "presentation" (that has a PDF file as icon...) and then input an image file. The JS then processed the file type to determine the slide type. As this was slightly confusing for the end user, we decided to use the same approach as the form view, meaning you FIRST select the type of slide you want and THEN input the content as a local file or external link. Since we now handle all types of documents when using an external Google Drive link, the various screen and text helpers have been reworked accordingly. 3. Handle more types of slides with the new "slide_type" field This commit also introduces a new "slide_type" field. (Don't get confused, the previous "slide_type" field has been renamed to "slide_category" in an earlier rename commit). This slide_type is a refined slide_category: - Videos are split into 'youtube_video' and 'google_drive_video' - Documents are refined based on the file mime_type if it's external content For example, if you link a Excel file, the slide_type will be 'sheet'. If you link a Google Doc, the slide_type will be 'doc' etc... (Local documents are only PDFs since it's the only type we support for now) This small change allows to include minor nice details into views, such as a refined icon for each slide_type on the website. LINKS Task-2510174 UPG PR odoo/upgrade#2498 Part-of: odoo/odoo#71477 --- addons/website_slides/__manifest__.py | 2 +- addons/website_slides/controllers/main.py | 76 ++- .../website_slides/data/slide_slide_demo.xml | 17 +- addons/website_slides/models/slide_channel.py | 1 - addons/website_slides/models/slide_slide.py | 595 ++++++++++++------ .../src/js/slides_course_fullscreen_player.js | 16 +- .../static/src/js/slides_upload.js | 194 ++++-- .../static/src/js/tours/slides_tour.js | 4 +- .../static/src/scss/website_slides.scss | 19 + .../src/xml/website_slides_fullscreen.xml | 2 +- .../static/src/xml/website_slides_upload.xml | 151 ++++- addons/website_slides/tests/common.py | 4 +- addons/website_slides/tests/test_karma.py | 4 +- .../website_slides/tests/test_slide_utils.py | 32 +- .../website_slides/tests/test_statistics.py | 4 - .../website_slides/tests/test_ui_wslides.py | 2 +- .../views/slide_slide_views.xml | 35 +- .../views/website_slides_templates_course.xml | 27 +- .../views/website_slides_templates_lesson.xml | 28 +- .../website_slides_templates_lesson_embed.xml | 4 +- ...ite_slides_templates_lesson_fullscreen.xml | 5 +- .../models/slide_slide.py | 10 + .../views/website_slides_templates_course.xml | 2 +- 23 files changed, 864 insertions(+), 370 deletions(-) diff --git a/addons/website_slides/__manifest__.py b/addons/website_slides/__manifest__.py index 97677eab787..0562c75d486 100644 --- a/addons/website_slides/__manifest__.py +++ b/addons/website_slides/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'eLearning', - 'version': '2.4', + 'version': '2.5', 'sequence': 125, 'summary': 'Manage and publish an eLearning platform', 'website': 'https://www.odoo.com/app/elearning', diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index e52856146dc..fe4e0c30142 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -1053,22 +1053,64 @@ class WebsiteSlides(WebsiteProfile): # -------------------------------------------------- @http.route(['/slides/prepare_preview'], type='json', auth='user', methods=['POST'], website=True) - def prepare_preview(self, **data): + def prepare_preview(self, channel_id, slide_category, url=None): + """ Will attempt to fetch external metadata for this slide from the correct + source (YouTube, Google Drive, ...). + + To take advantage of the slide business method, we create a temporary slide record before + fetching the metadata. + This allows a lot of code simplification, since we use "new", it will not created anything + in database. """ + + if not url: + return {} + Slide = request.env['slide.slide'] - unused, document_id = Slide._find_document_data_from_url(data['url']) - preview = {} - if not document_id: - preview['error'] = _('Please enter valid youtube or google doc url') - return preview - existing_slide = Slide.search([('channel_id', '=', int(data['channel_id'])), ('document_id', '=', document_id)], limit=1) - if existing_slide: - preview['error'] = _('This video already exists in this channel on the following slide: %s', existing_slide.name) - return preview - values = Slide._parse_document_url(data['url'], only_preview_fields=True) - if values.get('error'): - preview['error'] = values['error'] - return preview - return values + + if slide_category == 'video': + identical_video = request.env['slide.slide'] + existing_videos = Slide.search([ + ('channel_id', '=', int(channel_id)), + ('slide_category', '=', 'video') + ]) + + slide = Slide.new({ + 'channel_id': int(channel_id), + 'name': 'memory_record_for_computed_fields', + 'slide_category': 'video', + 'url': url + }) + + if not slide.video_source_type: + slide.unlink() + return {'error': _('Please enter valid YouTube or Google Drive Link')} + + if slide.video_source_type == 'youtube': + identical_video = existing_videos.filtered( + lambda existing_video: slide.youtube_id == existing_video.youtube_id) + elif slide.video_source_type == 'google_drive': + identical_video = existing_videos.filtered( + lambda existing_video: slide.google_drive_id == existing_video.google_drive_id) + if identical_video: + identical_video_name = identical_video[0].name + return {'error': _('This video already exists in this channel on the following content: %s', identical_video_name)} + elif slide_category in ['document', 'infographic']: + slide = Slide.new({ + 'channel_id': int(channel_id), + 'name': 'memory_record_for_computed_fields', + 'slide_category': slide_category, + 'source_type': 'external', + 'url': url + }) + + if not slide.google_drive_id: + return {'error': _('Please enter valid Google Drive Link')} + + slide_values, error = slide._fetch_external_metadata(image_url_only=True) + if error: + return {'error': error} + + return slide_values @http.route(['/slides/add_slide'], type='json', auth='user', methods=['POST'], website=True) def create_slide(self, *args, **post): @@ -1138,8 +1180,8 @@ class WebsiteSlides(WebsiteProfile): } def _get_valid_slide_post_values(self): - return ['name', 'url', 'tag_ids', 'slide_category', 'channel_id', 'is_preview', - 'mime_type', 'binary_content', 'description', 'image_1920', 'is_published'] + return ['name', 'url', 'video_url', 'document_google_url', 'image_google_url', 'tag_ids', 'slide_category', 'channel_id', + 'is_preview', 'binary_content', 'description', 'image_1920', 'is_published', 'source_type'] @http.route(['/slides/tag/search_read'], type='json', auth='user', methods=['POST'], website=True) def slide_tag_search_read(self, fields, domain): diff --git a/addons/website_slides/data/slide_slide_demo.xml b/addons/website_slides/data/slide_slide_demo.xml index 0873542073d..029ae026aa4 100644 --- a/addons/website_slides/data/slide_slide_demo.xml +++ b/addons/website_slides/data/slide_slide_demo.xml @@ -8,7 +8,7 @@ 1 - presentation + document @@ -211,7 +211,6 @@ 5 https://www.youtube.com/watch?v=QYmgrw0PgLU - QYmgrw0PgLU video @@ -329,7 +328,7 @@ 6 - presentation + document @@ -344,7 +343,6 @@ 7 https://www.youtube.com/watch?v=l0JZ25VvbwE - l0JZ25VvbwE video @@ -380,7 +378,7 @@ 1 - presentation + document @@ -512,7 +510,6 @@ 3 https://www.youtube.com/watch?v=ebBez6bcSEc - ebBez6bcSEc video @@ -529,7 +526,7 @@ 5 - presentation + document @@ -576,7 +573,6 @@ 3 https://www.youtube.com/watch?v=PYr1rK8pS30 - PYr1rK8pS30 video @@ -655,7 +651,6 @@ 2 https://www.youtube.com/watch?v=bvSe6r5BpaY - bvSe6r5BpaY video @@ -741,7 +736,7 @@ 1 - presentation + document @@ -768,7 +763,6 @@ 2 https://www.youtube.com/watch?v=5WMqwTnZ-qs - 5WMqwTnZ-qs video @@ -783,7 +777,6 @@ 4 https://www.youtube.com/watch?v=grrXe1QZNzQ - grrXe1QZNzQ video diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index 111fd812ebf..b4aa596645c 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -183,7 +183,6 @@ class Channel(models.Model): ) promoted_slide_id = fields.Many2one('slide.slide', string='Promoted Slide') access_token = fields.Char("Security Token", copy=False, default=_default_access_token) - nbr_presentation = fields.Integer('Presentations', compute='_compute_slides_statistics', store=True) nbr_document = fields.Integer('Documents', compute='_compute_slides_statistics', store=True) nbr_video = fields.Integer('Videos', compute='_compute_slides_statistics', store=True) nbr_infographic = fields.Integer('Infographics', compute='_compute_slides_statistics', store=True) diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index d5bf601544d..c0f34daf1fd 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -4,23 +4,23 @@ import base64 import datetime import io +import logging import re import requests import PyPDF2 -import json from dateutil.relativedelta import relativedelta from markupsafe import Markup -from PIL import Image from werkzeug import urls from odoo import api, fields, models, _ -from odoo.addons.http_routing.models.ir_http import slug -from odoo.exceptions import UserError, AccessError +from odoo.addons.http_routing.models.ir_http import slug, url_for +from odoo.exceptions import RedirectWarning, UserError, AccessError from odoo.http import request -from odoo.addons.http_routing.models.ir_http import url_for from odoo.tools import html2plaintext, sql +_logger = logging.getLogger(__name__) + class SlidePartnerRelation(models.Model): _name = 'slide.slide.partner' @@ -88,8 +88,12 @@ class Slide(models.Model): } _order = 'sequence asc, is_category asc, id asc' + YOUTUBE_VIDEO_ID_REGEX = r'^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*' + GOOGLE_DRIVE_DOCUMENT_ID_REGEX = r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)' + # description name = fields.Char('Title', required=True, translate=True) + image_1920 = fields.Image(compute="_compute_image_1920", store=True, readonly=False) # image.mixin override active = fields.Boolean(default=True, tracking=100) sequence = fields.Integer('Sequence', default=0) user_id = fields.Many2one('res.users', string='Uploaded by', default=lambda self: self.env.uid) @@ -121,20 +125,54 @@ class Slide(models.Model): slide_category = fields.Selection([ ('infographic', 'Infographic'), ('webpage', 'Web Page'), - ('presentation', 'Presentation'), ('document', 'Document'), ('video', 'Video'), ('quiz', "Quiz")], string='Category', required=True, - default='document', - help="The document category will be set automatically based on the document URL and properties (e.g. height and width for presentation and document).") + default='document') + source_type = fields.Selection([ + ('local_file', 'Local File'), + ('external', 'External (Google Drive)')], + default='local_file', required=True) + # generic + url = fields.Char('External URL', help="URL of the Google Drive file or URL of the YouTube video") binary_content = fields.Binary('File', attachment=True) - url = fields.Char('Document URL', help="Youtube or Google Document URL") - document_id = fields.Char('Document ID', help="Youtube or Google Document ID") slide_resource_ids = fields.One2many('slide.slide.resource', 'slide_id', string="Additional Resource for this slide") slide_resource_downloadable = fields.Boolean('Allow Download', default=True, help="Allow the user to download the content of the slide.") - mime_type = fields.Char('Mime-type') + # google + google_drive_id = fields.Char('Google Drive ID of the external URL', compute='_compute_google_drive_id') + # content - webpage html_content = fields.Html("HTML Content", help="Custom HTML content for slides of category 'Web Page'.", translate=True, sanitize_attributes=False, sanitize_form=False) + # content - images + image_binary_content = fields.Binary('Image Content', related='binary_content', readonly=False, + help="Used to filter file input to images only") + image_google_url = fields.Char('Image Link', related='url', readonly=False, + help="Link of the image (we currently only support Google Drive as source)") + # content - documents + slide_type = fields.Selection([ + ('image', 'Image'), + ('webpage', 'Web Page'), + ('quiz', 'Quiz'), + ('pdf', 'PDF'), + ('sheet', 'Sheet (Excel, Google Sheet, ...)'), + ('doc', 'Document (Word, Google Doc, ...)'), + ('slides', 'Slides (PowerPoint, Google Slides, ...)'), + ('youtube_video', 'YouTube Video'), + ('google_drive_video', 'Google Drive Video')], + string="Slide Type", compute='_compute_slide_type', store=True, readonly=False, + help="Subtype of the slide category, allows more precision on the actual file type / source type.") + document_google_url = fields.Char('Document Link', related='url', readonly=False, + help="Link of the document (we currently only support Google Drive as source)") + document_binary_content = fields.Binary('PDF Content', related='binary_content', readonly=False, + help="Used to filter file input to PDF only") + # content - videos + video_url = fields.Char('Video URL', related='url', readonly=False, + help="Link of the video (we support YouTube and Google Drive as sources)") + video_source_type = fields.Selection([ + ('youtube', 'YouTube'), + ('google_drive', 'Google Drive')], + string='Video Source', compute="_compute_video_source_type") + youtube_id = fields.Char('Video YouTube ID', compute='_compute_youtube_id') # website website_id = fields.Many2one(related='channel_id.website_id', readonly=True) date_published = fields.Datetime('Publish Date', readonly=True, tracking=False) @@ -156,7 +194,6 @@ class Slide(models.Model): channel_type = fields.Selection(related="channel_id.channel_type", string="Channel type") channel_allow_comment = fields.Boolean(related="channel_id.allow_comment", string="Allows comment") # Statistics in case the slide is a category - nbr_presentation = fields.Integer("Number of Presentations", compute='_compute_slides_statistics', store=True) nbr_document = fields.Integer("Number of Documents", compute='_compute_slides_statistics', store=True) nbr_video = fields.Integer("Number of Videos", compute='_compute_slides_statistics', store=True) nbr_infographic = fields.Integer("Number of Infographics", compute='_compute_slides_statistics', store=True) @@ -167,9 +204,17 @@ class Slide(models.Model): website_published = fields.Boolean(tracking=False) _sql_constraints = [ - ('exclusion_html_content_and_url', "CHECK(html_content IS NULL OR url IS NULL)", "A slide is either filled with a document url or HTML content. Not both.") + ('exclusion_html_content_and_url', "CHECK(html_content IS NULL OR url IS NULL)", "A slide is either filled with a url or HTML content. Not both.") ] + @api.depends('slide_category', 'source_type', 'image_binary_content') + def _compute_image_1920(self): + for slide in self: + if slide.slide_category == 'infographic' and slide.source_type == 'local_file' and slide.image_binary_content: + slide.image_1920 = slide.image_binary_content + elif not slide.image_1920: + slide.image_1920 = False + @api.depends('date_published', 'is_published') def _compute_is_new_slide(self): for slide in self: @@ -301,6 +346,33 @@ class Slide(models.Model): result[cid]['total_slides'] += slide_category_count return result + @api.depends('slide_category', 'source_type', 'video_source_type') + def _compute_slide_type(self): + """ For 'local content' or specific slide categories, the slide type is directly derived + from the slide category. + + For external content, the slide type is determined from the metadata and the mime_type. + (See #_fetch_google_drive_metadata() for more details).""" + + for slide in self: + if slide.slide_category == 'document': + if slide.source_type == 'local_file': + slide.slide_type = 'pdf' + elif slide.slide_type not in ['pdf', 'sheet', 'doc', 'slides']: + slide.slide_type = False + elif slide.slide_category == 'infographic': + slide.slide_type = 'image' + elif slide.slide_category == 'webpage': + slide.slide_type = 'webpage' + elif slide.slide_category == 'quiz': + slide.slide_type = 'quiz' + elif slide.slide_category == 'video' and slide.video_source_type == 'youtube': + slide.slide_type = 'youtube_video' + elif slide.slide_category == 'video' and slide.video_source_type == 'google_drive': + slide.slide_type = 'google_drive_video' + else: + slide.slide_type = False + @api.depends('slide_partner_ids.partner_id') @api.depends('uid') def _compute_user_membership_id(self): @@ -315,70 +387,99 @@ class Slide(models.Model): self.env['slide.slide.partner'] ) - @api.depends('document_id', 'slide_category', 'mime_type') + @api.depends('slide_category', 'google_drive_id', 'video_source_type', 'youtube_id') def _compute_embed_code(self): - base_url = request and request.httprequest.url_root - - for record in self: - embed_code_external = False - - if not base_url: - base_url = record.get_base_url() + request_base_url = request.httprequest.url_root if request else False + for slide in self: + base_url = request_base_url or slide.get_base_url() if base_url[-1] == '/': base_url = base_url[:-1] - if record.binary_content and (not record.document_id or record.slide_category in ['document', 'presentation']): - slide_url = base_url + url_for('/slides/embed/%s?page=1' % record.id) - slide_url_external = base_url + url_for('/slides/embed_external/%s?page=1' % record.id) + + embed_code = False + embed_code_external = False + if slide.slide_category == 'video': + if slide.video_source_type == 'youtube': + query_params = urls.url_parse(slide.video_url).query + query_params = query_params + '&theme=light' if query_params else 'theme=light' + embed_code = Markup('') % (slide.youtube_id, query_params) + elif slide.video_source_type == 'google_drive': + embed_code = Markup('') % (slide.google_drive_id) + elif slide.slide_category in ['infographic', 'document'] and slide.source_type == 'external' and slide.google_drive_id: + embed_code = Markup('') % (slide.google_drive_id) + elif slide.slide_category == 'document' and slide.source_type == 'local_file': + slide_url = base_url + url_for('/slides/embed/%s?page=1' % slide.id) + slide_url_external = base_url + url_for('/slides/embed_external/%s?page=1' % slide.id) base_embed_code = Markup('') - record.embed_code = base_embed_code % (slide_url, 315, 420) + embed_code = base_embed_code % (slide_url, 315, 420) embed_code_external = base_embed_code % (slide_url_external, 315, 420) - elif record.slide_category == 'video' and record.document_id: - if not record.mime_type: - # embed youtube video - query = urls.url_parse(record.url).query - query = query + '&theme=light' if query else 'theme=light' - record.embed_code = Markup('') % (record.document_id, query) + + slide.embed_code = embed_code + slide.embed_code_external = embed_code_external or embed_code + + @api.depends('video_url') + def _compute_video_source_type(self): + for slide in self: + video_source_type = False + youtube_match = re.match(self.YOUTUBE_VIDEO_ID_REGEX, slide.video_url) if slide.video_url else False + if youtube_match and len(youtube_match.groups()) == 2 and len(youtube_match.group(2)) == 11: + video_source_type = 'youtube' + if slide.video_url and not video_source_type and re.match(self.GOOGLE_DRIVE_DOCUMENT_ID_REGEX, slide.video_url): + video_source_type = 'google_drive' + + slide.video_source_type = video_source_type + + @api.depends('video_url', 'video_source_type') + def _compute_youtube_id(self): + for slide in self: + if slide.video_url and slide.video_source_type == 'youtube': + match = re.match(self.YOUTUBE_VIDEO_ID_REGEX, slide.video_url) + if match and len(match.groups()) == 2 and len(match.group(2)) == 11: + slide.youtube_id = match.group(2) else: - # embed google doc video - record.embed_code = Markup('') % (record.document_id) + slide.youtube_id = False else: - record.embed_code = False + slide.youtube_id = False - record.embed_code_external = embed_code_external or record.embed_code + @api.depends('url', 'document_google_url', 'image_google_url', 'video_url') + def _compute_google_drive_id(self): + """ Extracts the Google Drive ID from the url based on the slide category. """ - @api.onchange('url') + for slide in self: + url = slide.url or slide.document_google_url or slide.image_google_url or slide.video_url + google_drive_id = False + if url: + match = re.match(self.GOOGLE_DRIVE_DOCUMENT_ID_REGEX, url) + if match and len(match.groups()) == 2: + google_drive_id = match.group(2) + + slide.google_drive_id = google_drive_id + + @api.onchange('url', 'document_google_url', 'image_google_url', 'video_url') def _on_change_url(self): - self.ensure_one() - if self.url: - res = self._parse_document_url(self.url) - if res.get('error'): - raise UserError(res.get('error')) - values = res['values'] - if not values.get('document_id'): - raise UserError(_('Please enter valid Youtube or Google Doc URL')) - for key, value in values.items(): - self[key] = value + """ Keeping a 'onchange' because we want this behavior for the frontend. + Changing the document / video external URL will populate some metadata on the form view. + The slide metadata are also fetched in create / write overrides to ensure consistency. """ - @api.onchange('binary_content') - def _on_change_binary_content(self): - """ For PDFs, we assume that it takes 5 minutes to read a page. - If the selected file is not a PDF, it is an image (You can - only upload PDF or Image file) then the slide_category is changed - into infographic and the uploaded binary_content is transfered to the - image field. (It avoids the infinite loading in PDF viewer)""" - if self.binary_content: - data = base64.b64decode(self.binary_content) - if data.startswith(b'%PDF-'): - pdf = PyPDF2.PdfFileReader(io.BytesIO(data), overwriteWarnings=False, strict=False) - try: - pdf.getNumPages() - except PyPDF2.utils.PdfReadError: - return - self.completion_time = (5 * len(pdf.pages)) / 60 - else: - self.slide_category = 'infographic' - self.image_1920 = self.binary_content - self.binary_content = None + self.ensure_one() + if self.url or self.document_google_url or self.image_google_url or self.video_url: + slide_metadata, _error = self._fetch_external_metadata() + if slide_metadata: + self.update(slide_metadata) + + @api.onchange('document_binary_content') + def _on_change_document_binary_content(self): + if self.slide_category == 'document' and self.source_type == 'local_file' and self.document_binary_content: + completion_time = self._get_completion_time_pdf(base64.b64decode(self.document_binary_content)) + if completion_time: + self.completion_time = completion_time + + @api.onchange('slide_category') + def _on_change_slide_category(self): + """ Prevents mis-match when ones uploads an image and then a pdf without saving the form. """ + if self.slide_category != 'infographic' and self.image_binary_content: + self.image_binary_content = False + elif self.slide_category != 'document' and self.document_binary_content: + self.document_binary_content = False @api.depends('name', 'channel_id.website_id.domain') def _compute_website_url(self): @@ -409,29 +510,32 @@ class Slide(models.Model): # 'website_published' is handled by mixin values['date_published'] = False - if values.get('slide_category') == 'infographic' and not values.get('image_1920'): - values['image_1920'] = values['binary_content'] if values.get('is_category'): values['is_preview'] = True values['is_published'] = True if values.get('is_published') and not values.get('date_published'): values['date_published'] = datetime.datetime.now() - if values.get('url') and not values.get('document_id'): - doc_data = self._parse_document_url(values['url']).get('values', dict()) - for key, value in doc_data.items(): - values.setdefault(key, value) slide = super(Slide, self).create(values) + # avoid fetching external metadata when installing the module (i.e. for demo data) + # we also support a context key if you don't want to fetch the metadata when creating a slide + if any(values.get(url_param) for url_param in ['url', 'video_url', 'document_google_url', 'image_google_url']) \ + and not self.env.context.get('install_mode') \ + and not self.env.context.get('website_slides_skip_fetch_metadata'): + slide_metadata, _error = slide._fetch_external_metadata() + if slide_metadata: + # only update keys that are not set in the incoming values + slide.update({key: value for key, value in slide_metadata.items() if key not in values.keys()}) + + if not 'completion_time' not in values: + slide._on_change_document_binary_content() + if slide.is_published and not slide.is_category: slide._post_publication() return slide def write(self, values): - if values.get('url') and values['url'] != self.url: - doc_data = self._parse_document_url(values['url']).get('values', dict()) - for key, value in doc_data.items(): - values.setdefault(key, value) if values.get('is_category'): values['is_preview'] = True values['is_published'] = True @@ -441,6 +545,20 @@ class Slide(models.Model): self.date_published = datetime.datetime.now() self._post_publication() + # avoid fetching external metadata when installing the module (i.e. for demo data) + # we also support a context key if you don't want to fetch the metadata when modifying a slide + if any(values.get(url_param) for url_param in ['url', 'video_url', 'document_google_url', 'image_google_url']) \ + and not self.env.context.get('install_mode') \ + and not self.env.context.get('website_slides_skip_fetch_metadata'): + slide_metadata, _error = self._fetch_external_metadata() + if slide_metadata: + # only update keys that are not set in the incoming values and for which we don't have a value yet + self.update({ + key: value + for key, value in slide_metadata.items() + if key not in values.keys() and not any(slide[key] for slide in self) + }) + if 'is_published' in values or 'active' in values: # if the slide is published/unpublished, recompute the completion for the partners self.slide_partner_ids._set_completed_callback() @@ -741,165 +859,228 @@ class Slide(models.Model): # Parsing methods # -------------------------------------------------- - @api.model - def _fetch_data(self, base_url, params, content_type=False): - result = {'values': dict()} + def _fetch_external_metadata(self, image_url_only=False): + self.ensure_one() + + slide_metadata = {} + error = False + if self.slide_category == 'video' and self.video_source_type == 'youtube': + slide_metadata, error = self._fetch_youtube_metadata(image_url_only) + elif self.slide_category == 'video' and self.video_source_type == 'google_drive': + slide_metadata, error = self._fetch_google_drive_metadata(image_url_only) + elif self.slide_category in ['document', 'infographic'] and self.source_type == 'external': + # external documents & google drive videos share the same method currently + slide_metadata, error = self._fetch_google_drive_metadata(image_url_only) + + return slide_metadata, error + + def _fetch_youtube_metadata(self, image_url_only=False): + """ Fetches video metadata from the YouTube API. + + Returns a dict containing video metadata with the following keys (matching slide.slide fields): + - 'name' matching the video title + - 'description' matching the video description + - 'image_1920' binary data of the video thumbnail + OR 'image_url' containing an external link to the thumbnail when 'image_url_only' param is True + - 'completion_time' matching the video duration + The received duration is under a special format (e.g: PT1M21S15, meaning 1h 21m 15s). + + :param image_url_only: if True, will return 'image_url' instead of binary data + Typically used when displaying a slide preview to the end user. + :return a tuple (values, error) containing the values of the slide and a potential error + (e.g: 'Video could not be found') """ + + self.ensure_one() + google_app_key = self.env['website'].get_current_website().website_slide_google_app_key + error_message = False try: - response = requests.get(base_url, timeout=3, params=params) + response = requests.get( + 'https://www.googleapis.com/youtube/v3/videos', + timeout=3, + params={ + 'fields': 'items(id,snippet,contentDetails)', + 'id': self.youtube_id, + 'key': google_app_key, + 'part': 'snippet,contentDetails' + } + ) response.raise_for_status() - if content_type == 'json': - result['values'] = response.json() - elif content_type in ('image', 'pdf'): - result['values'] = base64.b64encode(response.content) - else: - result['values'] = response.content except requests.exceptions.HTTPError as e: - result['error'] = e.response.content + error_message = e.response.content + if 'application/json' in e.response.headers.get('content-type'): + json_response = e.response.json() + if json_response.get('error', {}).get('code') == 404: + return {}, _('Your video could not be found on YouTube, please check the link and/or privacy settings') except requests.exceptions.ConnectionError as e: - result['error'] = str(e) - return result + error_message = str(e) - def _find_document_data_from_url(self, url): - url_obj = urls.url_parse(url) - if url_obj.ascii_host == 'youtu.be': - return ('youtube', url_obj.path[1:] if url_obj.path else False) - elif url_obj.ascii_host in ('youtube.com', 'www.youtube.com', 'm.youtube.com', 'www.youtube-nocookie.com'): - v_query_value = url_obj.decode_query().get('v') - if v_query_value: - return ('youtube', v_query_value) - split_path = url_obj.path.split('/') - if len(split_path) >= 3 and split_path[1] in ('v', 'embed'): - return ('youtube', split_path[2]) + if not error_message: + response = response.json() + if response.get('error'): + error_message = response.get('error', {}).get('errors', [{}])[0].get('reason') - expr = re.compile(r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)') - arg = expr.match(url) - document_id = arg and arg.group(2) or False - if document_id: - return ('google', document_id) + if not response.get('items'): + error_message = _('Your video could not be found on YouTube, please check the link and/or privacy settings') - return (None, False) - - def _parse_document_url(self, url, only_preview_fields=False): - document_source, document_id = self._find_document_data_from_url(url) - if document_source and hasattr(self, '_parse_%s_document' % document_source): - return getattr(self, '_parse_%s_document' % document_source)(document_id, only_preview_fields) - return {'error': _('Unknown document')} - - def _parse_youtube_document(self, document_id, only_preview_fields): - """ If we receive a duration (YT video), we use it to determine the slide duration. - The received duration is under a special format (e.g: PT1M21S15, meaning 1h 21m 15s). """ - - key = self.env['website'].get_current_website().website_slide_google_app_key - fetch_res = self._fetch_data('https://www.googleapis.com/youtube/v3/videos', {'id': document_id, 'key': key, 'part': 'snippet,contentDetails', 'fields': 'items(id,snippet,contentDetails)'}, 'json') - if fetch_res.get('error'): - return {'error': self._extract_google_error_message(fetch_res.get('error'))} - - values = {'slide_category': 'video', 'document_id': document_id} - items = fetch_res['values'].get('items') - if not items: - return {'error': _('Please enter valid Youtube or Google Doc URL')} - youtube_values = items[0] + if error_message: + _logger.warning('Could not fetch YouTube metadata: %s', error_message) + return {}, error_message + slide_metadata = {'slide_type': 'youtube_video'} + youtube_values = response.get('items')[0] youtube_duration = youtube_values.get('contentDetails', {}).get('duration') if youtube_duration: parsed_duration = re.search(r'^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$', youtube_duration) if parsed_duration: - values['completion_time'] = (int(parsed_duration.group(1) or 0)) + \ - (int(parsed_duration.group(2) or 0) / 60) + \ - (int(parsed_duration.group(3) or 0) / 3600) + slide_metadata['completion_time'] = (int(parsed_duration.group(1) or 0)) + \ + (int(parsed_duration.group(2) or 0) / 60) + \ + (int(parsed_duration.group(3) or 0) / 3600) if youtube_values.get('snippet'): snippet = youtube_values['snippet'] - if only_preview_fields: - values.update({ - 'url_src': snippet['thumbnails']['high']['url'], - 'title': snippet['title'], - 'description': snippet['description'] - }) - - return values - - values.update({ + slide_metadata.update({ 'name': snippet['title'], - 'image_1920': self._fetch_data(snippet['thumbnails']['high']['url'], {}, 'image')['values'], 'description': snippet['description'], - 'mime_type': False, }) - return {'values': values} - def _extract_google_error_message(self, error): - """ - See here for Google error format - https://developers.google.com/drive/api/v3/handle-errors - """ - try: - error = json.loads(error) - error = (error.get('error', {}).get('errors', []) or [{}])[0].get('reason') - except json.decoder.JSONDecodeError: - error = str(error) + thumbnail_url = snippet['thumbnails']['high']['url'] + if image_url_only: + slide_metadata['image_url'] = thumbnail_url + else: + slide_metadata['image_1920'] = base64.b64encode( + requests.get(thumbnail_url, timeout=3).content + ) - if error == 'keyInvalid': - return _('Your Google API key is invalid, please update it in your settings.\nSettings > Website > Features > API Key') + return slide_metadata, None - return _('Could not fetch data from url. Document or access right not available:\n%s', error) + def _fetch_google_drive_metadata(self, image_url_only=False): + """ Fetches document / video metadata from the Google Drive API. - @api.model - def _parse_google_document(self, document_id, only_preview_fields): - def get_slide_category(vals): - # TDE FIXME: WTF ?? - slide_category = 'presentation' - if vals.get('image_1920'): - image = Image.open(io.BytesIO(base64.b64decode(vals['image_1920']))) - width, height = image.size - if height > width: - return 'document' - return slide_category + Returns a dict containing metadata with the following keys (matching slide.slide fields): + - 'name' matching the external file title + - 'image_1920' binary data of the file thumbnail + OR 'image_url' containing an external link to the thumbnail when 'image_url_only' param is True + - 'completion_time' which is computed for 2 types of files: + - pdf files where we download the content and then use slide.slide#_get_completion_time_pdf() + - videos where we use the 'videoMediaMetadata' to extract the 'durationMillis' + + :param image_url_only: if True, will return 'image_url' instead of binary data + Typically used when displaying a slide preview to the end user. + :return a tuple (values, error) containing the values of the slide and a potential error + (e.g: 'File could not be found') """ - # Google drive doesn't use a simple API key to access the data, but requires an access - # token. However, this token is generated in module google_drive, which is not in the - # dependencies of website_slides. We still keep the 'key' parameter just in case, but that - # is probably useless. params = {} params['projection'] = 'BASIC' if 'google.drive.config' in self.env: - access_token = self.env['google.drive.config'].get_access_token() + access_token = False + try: + access_token = self.env['google.drive.config'].get_access_token() + except (RedirectWarning, UserError): + pass # ignore and use the 'key' fallback + if access_token: params['access_token'] = access_token + if not params.get('access_token'): params['key'] = self.env['website'].get_current_website().website_slide_google_app_key - fetch_res = self._fetch_data('https://www.googleapis.com/drive/v2/files/%s' % document_id, params, "json") - if fetch_res.get('error'): - return {'error': self._extract_google_error_message(fetch_res.get('error'))} + error_message = False + try: + response = requests.get( + 'https://www.googleapis.com/drive/v2/files/%s' % self.google_drive_id, + timeout=3, + params=params + ) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + error_message = e.response.content + if 'application/json' in e.response.headers.get('content-type'): + json_response = e.response.json() + if json_response.get('error', {}).get('code') == 404: + # in case we don't find the file on GDrive, we want to give some feedback to our user + return {}, _('Your file could not be found on Google Drive, please check the link and/or privacy settings') + except requests.exceptions.ConnectionError as e: + error_message = str(e) - google_values = fetch_res['values'] - if only_preview_fields: - return { - 'url_src': google_values['thumbnailLink'], - 'title': google_values['title'], - } + if not error_message: + response = response.json() + if response.get('error'): + error_message = response.get('error', {}).get('errors', [{}])[0].get('reason') - values = { - 'name': google_values['title'], - 'image_1920': self._fetch_data(google_values['thumbnailLink'].replace('=s220', ''), {}, 'image')['values'], - 'mime_type': google_values['mimeType'], - 'document_id': document_id, + if error_message: + _logger.warning('Could not fetch Google Drive metadata: %s', error_message) + return {}, error_message + + google_drive_values = response + slide_metadata = { + 'name': google_drive_values.get('title') } - if google_values['mimeType'].startswith('video/'): - values['slide_category'] = 'video' - elif google_values['mimeType'].startswith('image/'): - values['binary_content'] = values['image_1920'] - values['slide_category'] = 'infographic' - elif google_values['mimeType'].startswith('application/vnd.google-apps'): - values['slide_category'] = get_slide_category(values) - if 'exportLinks' in google_values: - values['binary_content'] = self._fetch_data(google_values['exportLinks']['application/pdf'], params, 'pdf')['values'] - elif google_values['mimeType'] == 'application/pdf': - # TODO: Google Drive PDF document doesn't provide plain text transcript - values['binary_content'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values'] - values['slide_category'] = get_slide_category(values) - return {'values': values} + if google_drive_values.get('thumbnailLink'): + # small trick, we remove '=s220' to get a higher definition + thumbnail_url = google_drive_values['thumbnailLink'].replace('=s220', '') + if image_url_only: + slide_metadata['image_url'] = thumbnail_url + else: + slide_metadata['image_1920'] = base64.b64encode( + requests.get(thumbnail_url, timeout=3).content + ) + + if self.slide_category == 'document': + sheet_mimetypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.google-apps.spreadsheet' + ] + + doc_mimetypes = [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.google-apps.document' + ] + + slides_mimetypes = [ + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.google-apps.presentation' + ] + + mime_type = google_drive_values.get('mimeType') + if mime_type == 'application/pdf': + slide_metadata['slide_type'] = 'pdf' + if google_drive_values.get('downloadUrl'): + # attempt to download PDF content to extract a completion_time based on the number of pages + try: + pdf_response = requests.get(google_drive_values.get('downloadUrl'), timeout=5) + completion_time = self._get_completion_time_pdf(pdf_response.content) + if completion_time: + slide_metadata['completion_time'] = completion_time + except Exception: + pass # fail silently as this is nice to have + elif mime_type in sheet_mimetypes: + slide_metadata['slide_type'] = 'sheet' + elif mime_type in doc_mimetypes: + slide_metadata['slide_type'] = 'doc' + elif mime_type in slides_mimetypes: + slide_metadata['slide_type'] = 'slides' + elif mime_type and mime_type.startswith('image/'): + # image and videos should be input using another "slide_category" but let's be nice and + # assign them a matching slide_type + slide_metadata['slide_type'] = 'image' + elif mime_type and mime_type.startswith('video/'): + slide_metadata['slide_type'] = 'google_drive_video' + + elif self.slide_category == 'video': + completion_time = float( + google_drive_values.get('videoMediaMetadata', {}).get('durationMillis', 0) + ) / (60 * 60 * 1000) # millis to hours conversion + if completion_time: + slide_metadata['completion_time'] = completion_time + + return slide_metadata, None def _default_website_meta(self): res = super(Slide, self)._default_website_meta() @@ -913,6 +1094,20 @@ class Slide(models.Model): # Data / Misc # --------------------------------------------------------- + def _get_completion_time_pdf(self, data_bytes): + """ For PDFs, we assume that it takes 5 minutes to read a page. + This method receives the data of the PDF as bytes. """ + + if data_bytes.startswith(b'%PDF-'): + try: + pdf = PyPDF2.PdfFileReader(io.BytesIO(data_bytes), overwriteWarnings=False) + return (5 * len(pdf.pages)) / 60 + except Exception: + pass # as this is a nice to have, fail silently + + return False + + def get_backend_menu_id(self): return self.env.ref('website_slides.website_slides_menu_root').id diff --git a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js index 5e4974c0437..9fd92d1e587 100644 --- a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js +++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js @@ -512,14 +512,22 @@ slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : ""; } else if (slideData.category === 'infographic') { slideData.embedUrl = _.str.sprintf('/web/image/slide.slide/%s/image_1024', slideData.id); - } else if (_.contains(['document', 'presentation'], slideData.category)) { + } else if (slideData.category === 'document') { slideData.embedUrl = $(slideData.embedCode).attr('src'); } // fill empty property to allow searching on it with _.filter(list, matcher) slideData.isQuiz = !!slideData.isQuiz; slideData.hasQuestion = !!slideData.hasQuestion; // technical settings for the Fullscreen to work - slideData._autoSetDone = _.contains(['infographic', 'presentation', 'document', 'webpage'], slideData.category) && !slideData.hasQuestion; + var autoSetDone = false; + if (!slideData.hasQuestion) { + if (_.contains(['infographic', 'document', 'webpage'], slideData.category)) { + autoSetDone = true; // images, documents (local + external) and web pages are marked as completed when opened + } else if (slideData.category === 'video' && slideData.videoSourceType === 'google_drive') { + autoSetDone = true; // google drive videos do not benefit from the YouTube integration and are marked as completed when opened + } + } + slideData._autoSetDone = autoSetDone; }); return slidesDataList; }, @@ -563,7 +571,7 @@ } // render slide content - if (_.contains(['document', 'presentation', 'infographic'], slide.category)) { + if (_.contains(['document', 'infographic'], slide.category)) { $content.html(QWeb.render('website.slides.fullscreen.content', {widget: this})); } else if (slide.category === 'video') { this.videoPlayer = new VideoPlayer(this, slide); @@ -627,7 +635,7 @@ return self._renderSlide(); }).then(function() { if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call - if (['document', 'presentation'].includes(slide.category)) { + if (slide.category === 'document') { // only set the slide as completed after iFrame is loaded to avoid concurrent execution with 'embedUrl' controller self.el.querySelector('iframe.o_wslides_iframe_viewer').addEventListener('load', () => self._setCompleted(slide.id)); } else { diff --git a/addons/website_slides/static/src/js/slides_upload.js b/addons/website_slides/static/src/js/slides_upload.js index 14c605ee323..d36abe64b05 100644 --- a/addons/website_slides/static/src/js/slides_upload.js +++ b/addons/website_slides/static/src/js/slides_upload.js @@ -12,7 +12,10 @@ var SlideUploadDialog = Dialog.extend({ 'click .o_wslides_js_upload_install_button': '_onClickInstallModule', 'click .o_wslides_select_category': '_onClickSlideCategoryIcon', 'change input#upload': '_onChangeSlideUpload', - 'change input#url': '_onChangeSlideUrl', + 'change input#video_url': '_onChangeVideoUrl', + 'change input#image_google_url': '_onChangeImageUrl', + 'change input#document_google_url': '_onChangeDocumentUrl', + 'change input[name="source_type"]': '_onChangeSourceType', }), /** @@ -24,7 +27,7 @@ var SlideUploadDialog = Dialog.extend({ */ init: function (parent, options) { options = _.defaults(options || {}, { - title: _t("Upload a document"), + title: _t("Upload new content"), size: 'medium', }); this._super(parent, options); @@ -99,11 +102,12 @@ var SlideUploadDialog = Dialog.extend({ }); })); }, - _fetchUrlPreview: function (url) { + _fetchUrlPreview: function (url, slideCategory) { return this._rpc({ route: '/slides/prepare_preview/', params: { 'url': url, + 'slide_category': slideCategory, 'channel_id': this.channelID }, }); @@ -126,46 +130,47 @@ var SlideUploadDialog = Dialog.extend({ * @private */ _formValidateGetValues: async function (forcePublished) { - var canvas = this.$('#data_canvas')[0]; - var values = _.extend({ - 'channel_id': this.channelID, - 'name': this._formGetFieldValue('name'), - 'url': this._formGetFieldValue('url'), - 'description': this._formGetFieldValue('description'), - 'duration': this._formGetFieldValue('duration'), - 'is_published': forcePublished, - }, this._getSelect2DropdownValues()); // add tags and category - + var slideCategory = 'document'; // default slide_category (for webpage for instance) if (_.contains(this.slide_category_data), this.get('state')) { - values['slide_category'] = this.get('state'); + slideCategory = this.get('state'); } + var sourceType = 'local_file'; + if (slideCategory === 'video') { + sourceType = 'external'; // force external for videos + } else { + sourceType = this.$('input[name="source_type"]:checked').data('value'); + } + var values = _.extend({ + 'channel_id': this.channelID, + 'description': this._formGetFieldValue('description'), + 'document_google_url': this._formGetFieldValue('document_google_url'), + 'duration': this._formGetFieldValue('duration'), + 'image_google_url': this._formGetFieldValue('image_google_url'), + 'is_published': forcePublished, + 'name': this._formGetFieldValue('name'), + 'slide_category': slideCategory, + 'source_type': sourceType, + 'video_url': this._formGetFieldValue('video_url'), + }, this._getSelect2DropdownValues()); // add tags and category + + var canvas = this.$('#data_canvas')[0]; if (this.file.type === 'application/pdf') { _.extend(values, { 'image_1920': canvas.toDataURL().split(',')[1], - 'slide_category': canvas.height > canvas.width ? 'document' : 'presentation', - 'mime_type': this.file.type, + 'slide_category': 'document', 'binary_content': this.file.data }); } else if (values['slide_category'] === 'webpage') { _.extend(values, { - 'mime_type': 'text/html', - 'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data, + 'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPng() : this.file.data, }); } else if (/^image\/.*/.test(this.file.type)) { - const fileData = this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data; - if (values['slide_category'] === 'presentation') { - _.extend(values, { - 'slide_category': 'infographic', - 'mime_type': this.file.type === 'image/svg+xml' ? 'image/png' : this.file.type, - 'binary_content': fileData, - }); - } else { - _.extend(values, { - 'image_1920': fileData, - }); - } + _.extend(values, { + 'slide_category': 'infographic', + 'binary_content': this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data, + }); } return values; }, @@ -327,10 +332,15 @@ var SlideUploadDialog = Dialog.extend({ */ _setup: function () { this.slide_category_data = { - presentation: { + document: { icon: 'fa-file-pdf-o', - label: _t('Presentation'), - template: 'website.slide.upload.modal.presentation', + label: _t('Document'), + template: 'website.slide.upload.modal.document', + }, + infographic: { + icon: 'fa-file-image-o', + label: _t('Infographic'), + template: 'website.slide.upload.modal.infographic', }, webpage: { icon: 'fa-file-text', @@ -378,6 +388,68 @@ var SlideUploadDialog = Dialog.extend({ return png.split(',')[1]; }, + /** + * When the URL changes for slides of categories infographic, document and video, we attempt to fetch + * some metadata on YouTube / Google Drive (such as a name, a title, a duration, ...). + * + * @param {string} url + * @param {string} slideCategory + */ + _updateSlideMetadata: function (url, slideCategory) { + var self = this; + this._alertRemove(); + this.isValidUrl = false; + this.set('can_submit_form', false); + this._setModalLoading(true); + this._fetchUrlPreview(url, slideCategory).then(function (data) { + self.set('can_submit_form', true); + if (data.error) { + self._alertDisplay(data.error); + self._hidePreviewColumn(); + } else { + self.isValidUrl = true; + + if (data.name) { + self._formSetFieldValue('name', data.name); + } + if (data.description) { + self._formSetFieldValue('description', data.description); + } + if (data.completion_time) { + // hours to minutes conversion + self._formSetFieldValue('duration', Math.round(data.completion_time * 60)); + } + if (data.image_url) { + self.$('#slide-image').attr('src', data.image_url); + } + + if (!data.name && !data.description && !data.image_url) { + self._hidePreviewColumn(); + } else { + self._showPreviewColumn(); + } + } + + self._setModalLoading(false); + }); + }, + + /** + * Typically used when loading slide metadata. + * Since the request result will change form values, it's better to wait for the return of the + * request to avoid having the user type text that will be overridden (such as the slide name + * for example). + * + * @param {boolean} loading true to mask the modal with a loading screen, false to remove it + */ + _setModalLoading: function (loading) { + if (loading) { + this.$el.closest('.modal-content').append(QWeb.render('website.slide.upload.modal.loading')); + } else { + this.$el.closest('.modal-content').find('.o_wslides_slide_upload_loading').remove(); + } + }, + //-------------------------------------------------------------------------- // Handler //-------------------------------------------------------------------------- @@ -404,7 +476,7 @@ var SlideUploadDialog = Dialog.extend({ if (currentType === '_import') { this.set_title(_t("New Certification")); } else { - this.set_title(_t("Upload a document")); + this.set_title(_t("Upload new content")); } }, _onChangeCanSubmitForm: function (ev) { @@ -414,6 +486,28 @@ var SlideUploadDialog = Dialog.extend({ this.$('.o_w_slide_upload').button('loading'); } }, + /** + * When the user selects 'local_file' or 'external' as source type, we display the 'upload' + * field or the 'document_google_url' / 'image_google_url' fields respectively. + * We also toggle the 'required' attribute the same way. + * + * @param {Event} ev the onchange event + */ + _onChangeSourceType: function (ev) { + if (this.$('#source_type_local_file').is(':checked')) { + this.$('.o_wslides_js_slide_upload_local_file').removeClass('d-none'); + this.$('.o_wslides_js_slide_upload_external').addClass('d-none'); + + this.$('#upload').attr('required', 'required'); + this.$('#document_google_url, #image_google_url').removeAttr('required'); + } else if (this.$('#source_type_external').is(':checked')) { + this.$('.o_wslides_js_slide_upload_external').removeClass('d-none'); + this.$('.o_wslides_js_slide_upload_local_file').addClass('d-none'); + + this.$('#document_google_url, #image_google_url').attr('required', 'required'); + this.$('#upload').removeAttr('required'); + } + }, _onChangeSlideUpload: function (ev) { var self = this; this._alertRemove(); @@ -519,31 +613,15 @@ var SlideUploadDialog = Dialog.extend({ } } }, - _onChangeSlideUrl: function (ev) { - var self = this; - var url = $(ev.target).val(); - this._alertRemove(); - this.isValidUrl = false; - this.set('can_submit_form', false); - this._fetchUrlPreview(url).then(function (data) { - self.set('can_submit_form', true); - if (data.error) { - self._alertDisplay(data.error); - } else { - if (data.completion_time) { - // hours to minutes conversion - self._formSetFieldValue('duration', Math.round(data.completion_time * 60)); - } - self.$('#slide-image').attr('src', data.url_src); - self._formSetFieldValue('name', data.title); - self._formSetFieldValue('description', data.description); - - self.isValidUrl = true; - self._showPreviewColumn(); - } - }); + _onChangeDocumentUrl: function (ev) { + this._updateSlideMetadata($(ev.target).val(), 'document'); + }, + _onChangeImageUrl: function (ev) { + this._updateSlideMetadata($(ev.target).val(), 'infographic'); + }, + _onChangeVideoUrl: function (ev) { + this._updateSlideMetadata($(ev.target).val(), 'video'); }, - _onClickInstallModule: function (ev) { var $btn = $(ev.currentTarget); var moduleId = $btn.data('moduleId'); diff --git a/addons/website_slides/static/src/js/tours/slides_tour.js b/addons/website_slides/static/src/js/tours/slides_tour.js index 6d5396defcc..dab72c581ad 100644 --- a/addons/website_slides/static/src/js/tours/slides_tour.js +++ b/addons/website_slides/static/src/js/tours/slides_tour.js @@ -45,8 +45,8 @@ tour.register('slides_tour', { content: Markup(_t("Your first section is created, now it's time to add lessons to your course. Click on Add Content to upload a document, create a web page or link a video.")), position: 'bottom', }, { - trigger: 'a[data-slide-category="presentation"]', - content: Markup(_t("First, let's add a Presentation. It can be a .pdf or an image.")), + trigger: 'a[data-slide-category="document"]', + content: Markup(_t("First, let's add a Document. It has to be a .pdf file.")), position: 'bottom', }, { trigger: 'input#upload', diff --git a/addons/website_slides/static/src/scss/website_slides.scss b/addons/website_slides/static/src/scss/website_slides.scss index 6ec12ef722e..fb74c1d2377 100644 --- a/addons/website_slides/static/src/scss/website_slides.scss +++ b/addons/website_slides/static/src/scss/website_slides.scss @@ -515,6 +515,25 @@ $truncate-limits: 2, 3, 10; // Modals // ************************************************** +.o_w_slide_upload_modal_container { + .o_slide_preview { + display: flex; // not using d-flex because it messes with d-none + } + + .form-check { + line-height: 1.5rem; // necessary to align label correctly with radio button + + } + + .was-validated .form-check .form-check-label { + @include o-w-preserve-base; // avoid ugly green when form is marked as 'was-validated' + } +} + +.o_wslides_slide_upload_loading { + background-color: rgba(0, 0, 0, .3); +} + .o_wslides_quiz_modal { @include media-breakpoint-up (sm) { .modal-body { diff --git a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml index f22d109bfb2..d34749cb85f 100644 --- a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml +++ b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml @@ -1,7 +1,7 @@ - +