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 @@ - +