diff --git a/addons/test_website_slides_full/tests/test_ui_wslides.py b/addons/test_website_slides_full/tests/test_ui_wslides.py index 3943cd6daa1..689825f674d 100644 --- a/addons/test_website_slides_full/tests/test_ui_wslides.py +++ b/addons/test_website_slides_full/tests/test_ui_wslides.py @@ -148,7 +148,7 @@ class TestUi(TestUICommon): (0, 0, { 'name': 'DIY Furniture Certification', 'sequence': 1, - 'slide_type': 'certification', + 'slide_category': 'certification', 'category_id': False, 'is_published': True, 'is_preview': False, diff --git a/addons/website_slides/__manifest__.py b/addons/website_slides/__manifest__.py index 97677eab787..7081492b55a 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', @@ -14,7 +14,7 @@ Featuring * Integrated course and lesson management * Fullscreen navigation - * Support Youtube videos, Google documents, PDF, images, web pages + * Support Youtube videos, Google documents, PDF, images, articles * Test knowledge with quizzes * Filter and Tag * Statistics diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index d3902452fe3..b3bb297e078 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -67,7 +67,7 @@ class WebsiteSlides(WebsiteProfile): def _set_completed_slide(self, slide): # quiz use their specific mechanism to be marked as done - if slide.slide_type == 'quiz' or slide.question_ids: + if slide.slide_category == 'quiz' or slide.question_ids: raise werkzeug.exceptions.Forbidden(_("Slide with questions must be marked as done when submitting all good answers ")) if slide.website_published and slide.channel_id.is_member: slide.action_set_completed() @@ -331,12 +331,12 @@ class WebsiteSlides(WebsiteProfile): return request.render('website_slides.courses_home', values) @http.route(['/slides/all', '/slides/all/tag/'], type='http', auth="public", website=True, sitemap=True) - def slides_channel_all(self, slide_type=None, slug_tags=None, my=False, **post): + def slides_channel_all(self, slide_category=None, slug_tags=None, my=False, **post): """ Home page displaying a list of courses displayed according to some criterion and search terms. - :param string slide_type: if provided, filter the course to contain at - least one slide of type 'slide_type'. Used notably to display courses + :param string slide_category: if provided, filter the course to contain at + least one slide of type 'slide_category'. Used notably to display courses with certifications; :param string slug_tags: if provided, filter the slide.channels having the tag(s) (in comma separated slugified form); @@ -347,6 +347,10 @@ class WebsiteSlides(WebsiteProfile): * ``search``: filter on course description / name; """ + # retro-compatibility for older links, 'slide_category' field was previously named 'slide_type' + # can be safely removed after 15.3 (I swear though, don't be afraid, remove it!) + slide_category = slide_category or post.get('slide_type') + if slug_tags and request.httprequest.method == 'GET': # Redirect `tag-1,tag-2` to `tag-1` to disallow multi tags # in GET request for proper bot indexation; @@ -355,7 +359,7 @@ class WebsiteSlides(WebsiteProfile): # request and so clearly it's not SEO bot. tag_list = slug_tags.split(',') if len(tag_list) > 1 and not post.get('search'): - url = QueryURL('/slides/all', ['tag'], tag=tag_list[0], my=my, slide_type=slide_type)() + url = QueryURL('/slides/all', ['tag'], tag=tag_list[0], my=my, slide_category=slide_category)() return request.redirect(url, code=302) options = { @@ -367,7 +371,7 @@ class WebsiteSlides(WebsiteProfile): 'allowFuzzy': not post.get('noFuzzy'), 'my': my, 'tag': slug_tags or post.get('tag'), - 'slide_type': slide_type, + 'slide_category': slide_category, } search = post.get('search') order = self._channel_order_by_criterion.get(post.get('sorting')) @@ -390,7 +394,7 @@ class WebsiteSlides(WebsiteProfile): 'tag_groups': tag_groups, 'search_term': fuzzy_search_term or search, 'original_search': fuzzy_search_term and search, - 'search_slide_type': slide_type, + 'search_slide_category': slide_category, 'search_my': my, 'search_tags': search_tags, 'top3_users': self._get_top3_users(), @@ -416,15 +420,20 @@ class WebsiteSlides(WebsiteProfile): '/slides//category/', '/slides//category//page/', ], type='http', auth="public", website=True, sitemap=sitemap_slide) - def channel(self, channel, category=None, tag=None, page=1, slide_type=None, uncategorized=False, sorting=None, search=None, **kw): + def channel(self, channel, category=None, tag=None, page=1, slide_category=None, uncategorized=False, sorting=None, search=None, **kw): """ Will return all necessary data to display the requested slide_channel along with a possible category. """ + + # retro-compatibility for older links, 'slide_category' field was previously named 'slide_type' + # can be safely removed after 15.3 (I swear though, don't be afraid, remove it!) + slide_category = slide_category or kw.get('slide_type') + domain = self._get_channel_slides_base_domain(channel) pager_url = "/slides/%s" % (channel.id) pager_args = {} - slide_types = dict(request.env['slide.slide']._fields['slide_type']._description_selection(request.env)) + slide_categories = dict(request.env['slide.slide']._fields['slide_category']._description_selection(request.env)) if search: domain += [ @@ -443,9 +452,9 @@ class WebsiteSlides(WebsiteProfile): if uncategorized: domain += [('category_id', '=', False)] pager_args['uncategorized'] = 1 - elif slide_type: - domain += [('slide_type', '=', slide_type)] - pager_url += "?slide_type=%s" % slide_type + elif slide_category: + domain += [('slide_category', '=', slide_category)] + pager_url += "?slide_category=%s" % slide_category # sorting criterion if channel.channel_type == 'documentation': @@ -467,8 +476,8 @@ class WebsiteSlides(WebsiteProfile): query_string = "?search_category=%s" % category.id elif tag: query_string = "?search_tag=%s" % tag.id - elif slide_type: - query_string = "?search_slide_type=%s" % slide_type + elif slide_category: + query_string = "?search_slide_category=%s" % slide_category elif uncategorized: query_string = "?search_uncategorized=1" @@ -479,10 +488,10 @@ class WebsiteSlides(WebsiteProfile): # search 'search_category': category, 'search_tag': tag, - 'search_slide_type': slide_type, + 'search_slide_category': slide_category, 'search_uncategorized': uncategorized, 'query_string': query_string, - 'slide_types': slide_types, + 'slide_categories': slide_categories, 'sorting': actual_sorting, 'search': search, # display data @@ -704,8 +713,8 @@ class WebsiteSlides(WebsiteProfile): values.update({ 'search_category': slide.category_id if kwargs.get('search_category') else None, 'search_tag': request.env['slide.tag'].browse(int(kwargs.get('search_tag'))) if kwargs.get('search_tag') else None, - 'slide_types': dict(request.env['slide.slide']._fields['slide_type']._description_selection(request.env)) if kwargs.get('search_slide_type') else None, - 'search_slide_type': kwargs.get('search_slide_type'), + 'slide_categories': dict(request.env['slide.slide']._fields['slide_category']._description_selection(request.env)) if kwargs.get('search_slide_category') else None, + 'search_slide_category': kwargs.get('search_slide_category'), 'search_uncategorized': kwargs.get('search_uncategorized') }) @@ -724,7 +733,7 @@ class WebsiteSlides(WebsiteProfile): type='http', auth="public", website=True, sitemap=False) def slide_get_pdf_content(self, slide): response = werkzeug.wrappers.Response() - response.data = slide.datas and base64.b64decode(slide.datas) or b'' + response.data = slide.binary_content and base64.b64decode(slide.binary_content) or b'' response.mimetype = 'application/pdf' return response @@ -1044,28 +1053,77 @@ 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 + + additional_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': _("Could not find your video. Please check if your link is correct and if the video can be accessed.")} + + 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) + elif slide.video_source_type == 'vimeo': + identical_video = existing_videos.filtered( + lambda existing_video: slide.vimeo_id == existing_video.vimeo_id) + if identical_video: + identical_video_name = identical_video[0].name + additional_values['info'] = _('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} + + if additional_values: + slide_values.update(additional_values) + + return slide_values @http.route(['/slides/add_slide'], type='json', auth='user', methods=['POST'], website=True) def create_slide(self, *args, **post): # check the size only when we upload a file. - if post.get('datas'): - file_size = len(post['datas']) * 3 / 4 # base64 + if post.get('binary_content'): + file_size = len(post['binary_content']) * 3 / 4 # base64 if (file_size / 1024.0 / 1024.0) > 25: return {'error': _('File is too big. File size cannot exceed 25MB')} @@ -1117,9 +1175,9 @@ class WebsiteSlides(WebsiteProfile): channel._resequence_slides(slide, force_category=category) redirect_url = "/slides/slide/%s" % (slide.id) - if channel.channel_type == "training" and not slide.slide_type == "webpage": + if channel.channel_type == "training" and slide.slide_category not in ["article", "quiz"]: redirect_url = "/slides/%s" % (slug(channel)) - if slide.slide_type == 'webpage': + if slide.slide_category == 'article': redirect_url += "?enable_editor=1" return { 'url': redirect_url, @@ -1129,8 +1187,8 @@ class WebsiteSlides(WebsiteProfile): } def _get_valid_slide_post_values(self): - return ['name', 'url', 'tag_ids', 'slide_type', 'channel_id', 'is_preview', - 'mime_type', 'datas', '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/mail_template_data.xml b/addons/website_slides/data/mail_template_data.xml index c1c27b69103..6e67cbe762c 100644 --- a/addons/website_slides/data/mail_template_data.xml +++ b/addons/website_slides/data/mail_template_data.xml @@ -4,7 +4,7 @@ Slide Published - New {{ object.slide_type }} published on {{ object.channel_id.name }} + New {{ object.slide_category }} published on {{ object.channel_id.name }}

@@ -36,14 +36,14 @@ Slide Shared - {{ user.name }} shared a {{ object.slide_type }} with you! + {{ user.name }} shared a {{ object.slide_category }} with you! {{ user.email_formatted }} {{ ctx.get('email', '') }}

Hello

- Mitchell Admin shared the document Trees with you! + Mitchell Admin shared the document Trees with you!

diff --git a/addons/website_slides/data/slide_slide_demo.xml b/addons/website_slides/data/slide_slide_demo.xml index 7817d5db91c..1eea9658457 100644 --- a/addons/website_slides/data/slide_slide_demo.xml +++ b/addons/website_slides/data/slide_slide_demo.xml @@ -6,9 +6,9 @@ Gardening: The Know-How 1 - + - presentation + document @@ -29,7 +29,7 @@ Home Gardening 2 - infographic + infographic @@ -43,7 +43,7 @@ Mighty Carrots 3 - infographic + infographic @@ -56,9 +56,9 @@ How to Grow and Harvest The Best Strawberries | Basics 4 - + - document + document @@ -72,7 +72,7 @@ Test your knowledge 5 - quiz + quiz @@ -149,7 +149,7 @@ Tree Infographic 1 - infographic + infographic @@ -163,7 +163,7 @@ Interesting Tree Facts 2 - infographic + infographic @@ -177,7 +177,7 @@ Energy Efficiency Facts 3 - infographic + infographic @@ -211,8 +211,7 @@ 5 https://www.youtube.com/watch?v=QYmgrw0PgLU - QYmgrw0PgLU - video + video @@ -226,7 +225,7 @@ A little chat with Harry Potted 6 - webpage + article
@@ -327,9 +326,9 @@ 3 Main Methodologies 6 - + - presentation + document @@ -344,8 +343,7 @@ 7 https://www.youtube.com/watch?v=l0JZ25VvbwE - l0JZ25VvbwE - video + video @@ -378,9 +376,9 @@ Main Trees Categories 1 - + - presentation + document @@ -464,7 +462,7 @@ A Mighty Forest from Ages 2 - webpage + article
@@ -512,8 +510,7 @@ 3 https://www.youtube.com/watch?v=ebBez6bcSEc - ebBez6bcSEc - video + video @@ -527,9 +524,9 @@ Wood Characteristics 5 - + - presentation + document @@ -561,7 +558,7 @@ Comparing Hardness of Wood Species 2 - infographic + infographic @@ -576,8 +573,7 @@ 3 https://www.youtube.com/watch?v=PYr1rK8pS30 - PYr1rK8pS30 - video + video @@ -609,7 +605,7 @@ Foreword 1 - webpage + article
@@ -655,8 +651,7 @@ 2 https://www.youtube.com/watch?v=bvSe6r5BpaY - bvSe6r5BpaY - video + video @@ -670,7 +665,7 @@ Drawing 1 11 - infographic + infographic @@ -684,7 +679,7 @@ Drawing 2 12 - infographic + infographic @@ -697,9 +692,9 @@ Presentation 13 - + - document + document @@ -739,9 +734,9 @@ Unforgettable Tools 1 - + - presentation + document @@ -768,8 +763,7 @@ 2 https://www.youtube.com/watch?v=5WMqwTnZ-qs - 5WMqwTnZ-qs - video + video @@ -783,8 +777,7 @@ 4 https://www.youtube.com/watch?v=grrXe1QZNzQ - grrXe1QZNzQ - video + video @@ -797,7 +790,7 @@ Test your knowledge ! 6 - quiz + quiz diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index f1da46902d8..158a3fa2b36 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -183,11 +183,10 @@ 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) - nbr_webpage = fields.Integer("Webpages", compute='_compute_slides_statistics', store=True) + nbr_article = fields.Integer("Articles", compute='_compute_slides_statistics', store=True) nbr_quiz = fields.Integer("Number of Quizs", compute='_compute_slides_statistics', store=True) total_slides = fields.Integer('Number of Contents', compute='_compute_slides_statistics', store=True) total_views = fields.Integer('Visits', compute='_compute_slides_statistics', store=True) @@ -300,18 +299,18 @@ class Channel(models.Model): channel.slide_category_ids = channel.slide_ids.filtered(lambda slide: slide.is_category) channel.slide_content_ids = channel.slide_ids - channel.slide_category_ids - @api.depends('slide_ids.slide_type', 'slide_ids.is_published', 'slide_ids.completion_time', + @api.depends('slide_ids.slide_category', 'slide_ids.is_published', 'slide_ids.completion_time', 'slide_ids.likes', 'slide_ids.dislikes', 'slide_ids.total_views', 'slide_ids.is_category', 'slide_ids.active') def _compute_slides_statistics(self): default_vals = dict(total_views=0, total_votes=0, total_time=0, total_slides=0) - keys = ['nbr_%s' % slide_type for slide_type in self.env['slide.slide']._fields['slide_type'].get_values(self.env)] + keys = ['nbr_%s' % slide_category for slide_category in self.env['slide.slide']._fields['slide_category'].get_values(self.env)] default_vals.update(dict((key, 0) for key in keys)) result = dict((cid, dict(default_vals)) for cid in self.ids) read_group_res = self.env['slide.slide'].read_group( [('active', '=', True), ('is_published', '=', True), ('channel_id', 'in', self.ids), ('is_category', '=', False)], - ['channel_id', 'slide_type', 'likes', 'dislikes', 'total_views', 'completion_time'], - groupby=['channel_id', 'slide_type'], + ['channel_id', 'slide_category', 'likes', 'dislikes', 'total_views', 'completion_time'], + groupby=['channel_id', 'slide_category'], lazy=False) for res_group in read_group_res: cid = res_group['channel_id'][0] @@ -320,25 +319,25 @@ class Channel(models.Model): result[cid]['total_votes'] -= res_group.get('dislikes', 0) result[cid]['total_time'] += res_group.get('completion_time', 0) - type_stats = self._compute_slides_statistics_type(read_group_res) - for cid, cdata in type_stats.items(): + category_stats = self._compute_slides_statistics_category(read_group_res) + for cid, cdata in category_stats.items(): result[cid].update(cdata) for record in self: record.update(result.get(record.id, default_vals)) - def _compute_slides_statistics_type(self, read_group_res): - """ Compute statistics based on all existing slide types """ - slide_types = self.env['slide.slide']._fields['slide_type'].get_values(self.env) - keys = ['nbr_%s' % slide_type for slide_type in slide_types] + def _compute_slides_statistics_category(self, read_group_res): + """ Compute statistics based on all existing slide categories """ + slide_categories = self.env['slide.slide']._fields['slide_category'].get_values(self.env) + keys = ['nbr_%s' % slide_category for slide_category in slide_categories] result = dict((cid, dict((key, 0) for key in keys + ['total_slides'])) for cid in self.ids) for res_group in read_group_res: cid = res_group['channel_id'][0] - slide_type = res_group.get('slide_type') - if slide_type: - slide_type_count = res_group.get('__count', 0) - result[cid]['nbr_%s' % slide_type] = slide_type_count - result[cid]['total_slides'] += slide_type_count + slide_category = res_group.get('slide_category') + if slide_category: + slide_category_count = res_group.get('__count', 0) + result[cid]['nbr_%s' % slide_category] = slide_category_count + result[cid]['total_slides'] += slide_category_count return result def _compute_rating_stats(self): @@ -847,7 +846,7 @@ class Channel(models.Model): with_date = options['displayDetail'] my = options.get('my') search_tags = options.get('tag') - slide_type = options.get('slide_type') + slide_category = options.get('slide_category') domain = [website.website_domain()] if my: domain.append([('partner_ids', '=', self.env.user.partner_id.id)]) @@ -865,8 +864,8 @@ class Channel(models.Model): # OR inside a group, AND between groups. for group in grouped_tags: domain.append([('tag_ids', 'in', [tag.id for tag in grouped_tags[group]])]) - if slide_type and 'nbr_%s' % slide_type in self: - domain.append([('nbr_%s' % slide_type, '>', 0)]) + if slide_category and 'nbr_%s' % slide_category in self: + domain.append([('nbr_%s' % slide_category, '>', 0)]) search_fields = ['name'] fetch_fields = ['name', 'website_url'] mapping = { diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index 8a70d94a409..33eb7933f9a 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,13 @@ 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\/([^\/]*)' + VIMEO_VIDEO_ID_REGEX = r'\/\/(player.)?vimeo.com\/(?:[a-z]*\/)*([0-9]{6,11})\/?([0-9a-z]{6,11})?[?]?.*' + # 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) @@ -118,23 +123,60 @@ class Slide(models.Model): quiz_third_attempt_reward = fields.Integer("Reward: third attempt", default=5,) quiz_fourth_attempt_reward = fields.Integer("Reward: every attempt after the third try", default=2) # content - slide_type = fields.Selection([ - ('infographic', 'Infographic'), - ('webpage', 'Web Page'), - ('presentation', 'Presentation'), + slide_category = fields.Selection([ + ('infographic', 'Image'), + ('article', 'Article'), ('document', 'Document'), ('video', 'Video'), ('quiz', "Quiz")], - string='Type', required=True, - default='document', - help="The document type will be set automatically based on the document URL and properties (e.g. height and width for presentation and document).") - datas = 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") + string='Category', required=True, + default='document') + source_type = fields.Selection([ + ('local_file', 'Upload from Device'), + ('external', 'Retrieve from 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) 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') - html_content = fields.Html("HTML Content", help="Custom HTML content for slides of type 'Web Page'.", translate=True, sanitize_attributes=False, sanitize_form=False) + slide_resource_downloadable = fields.Boolean('Allow Download', default=False, help="Allow the user to download the content of the slide.") + # 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 'Article'.", 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'), + ('article', 'Article'), + ('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'), + ('vimeo_video', 'Vimeo 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 Link', related='url', readonly=False, + help="Link of the video (we support YouTube, Google Drive and Vimeo as sources)") + video_source_type = fields.Selection([ + ('youtube', 'YouTube'), + ('google_drive', 'Google Drive'), + ('vimeo', 'Vimeo')], + string='Video Source', compute="_compute_video_source_type") + youtube_id = fields.Char('Video YouTube ID', compute='_compute_youtube_id') + vimeo_id = fields.Char('Video Vimeo ID', compute='_compute_vimeo_id') # website website_id = fields.Many2one(related='channel_id.website_id', readonly=True) date_published = fields.Datetime('Publish Date', readonly=True, tracking=False) @@ -156,20 +198,27 @@ 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) - nbr_webpage = fields.Integer("Number of Webpages", compute='_compute_slides_statistics', store=True) + nbr_infographic = fields.Integer("Number of Images", compute='_compute_slides_statistics', store=True) + nbr_article = fields.Integer("Number of Articles", compute='_compute_slides_statistics', store=True) nbr_quiz = fields.Integer("Number of Quizs", compute="_compute_slides_statistics", store=True) total_slides = fields.Integer(compute='_compute_slides_statistics', store=True) is_published = fields.Boolean(tracking=1) 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: @@ -270,37 +319,66 @@ class Slide(models.Model): for slide in self: slide.embed_count = mapped_data.get(slide.id, 0) - @api.depends('slide_ids.sequence', 'slide_ids.slide_type', 'slide_ids.is_published', 'slide_ids.is_category') + @api.depends('slide_ids.sequence', 'slide_ids.slide_category', 'slide_ids.is_published', 'slide_ids.is_category') def _compute_slides_statistics(self): # Do not use dict.fromkeys(self.ids, dict()) otherwise it will use the same dictionnary for all keys. # Therefore, when updating the dict of one key, it updates the dict of all keys. - keys = ['nbr_%s' % slide_type for slide_type in self.env['slide.slide']._fields['slide_type'].get_values(self.env)] + keys = ['nbr_%s' % slide_category for slide_category in self.env['slide.slide']._fields['slide_category'].get_values(self.env)] default_vals = dict((key, 0) for key in keys + ['total_slides']) res = self.env['slide.slide'].read_group( [('is_published', '=', True), ('category_id', 'in', self.ids), ('is_category', '=', False)], - ['category_id', 'slide_type'], ['category_id', 'slide_type'], + ['category_id', 'slide_category'], ['category_id', 'slide_category'], lazy=False) - type_stats = self._compute_slides_statistics_type(res) + category_stats = self._compute_slides_statistics_category(res) for record in self: - record.update(type_stats.get(record._origin.id, default_vals)) + record.update(category_stats.get(record._origin.id, default_vals)) - def _compute_slides_statistics_type(self, read_group_res): - """ Compute statistics based on all existing slide types """ - slide_types = self.env['slide.slide']._fields['slide_type'].get_values(self.env) - keys = ['nbr_%s' % slide_type for slide_type in slide_types] + def _compute_slides_statistics_category(self, read_group_res): + """ Compute statistics based on all existing slide categories """ + slide_categories = self.env['slide.slide']._fields['slide_category'].get_values(self.env) + keys = ['nbr_%s' % slide_category for slide_category in slide_categories] result = dict((cid, dict((key, 0) for key in keys + ['total_slides'])) for cid in self.ids) for res_group in read_group_res: cid = res_group['category_id'][0] - slide_type = res_group.get('slide_type') - if slide_type: - slide_type_count = res_group.get('__count', 0) - result[cid]['nbr_%s' % slide_type] = slide_type_count - result[cid]['total_slides'] += slide_type_count + slide_category = res_group.get('slide_category') + if slide_category: + slide_category_count = res_group.get('__count', 0) + result[cid]['nbr_%s' % slide_category] = slide_category_count + 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 == 'article': + slide.slide_type = 'article' + 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' + elif slide.slide_category == 'video' and slide.video_source_type == 'vimeo': + slide.slide_type = 'vimeo_video' + else: + slide.slide_type = False + @api.depends('slide_partner_ids.partner_id') @api.depends('uid') def _compute_user_membership_id(self): @@ -315,70 +393,137 @@ class Slide(models.Model): self.env['slide.slide.partner'] ) - @api.depends('document_id', 'slide_type', '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.datas and (not record.document_id or record.slide_type 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.video_source_type == 'vimeo': + if '/' in slide.vimeo_id: + # in case of privacy 'with URL only', vimeo adds a token after the video ID + # the embed url needs to receive that token as a "h" parameter + [vimeo_id, vimeo_token] = slide.vimeo_id.split('/') + embed_code = Markup(""" + """) % ( + vimeo_id, vimeo_token) + else: + embed_code = Markup(""" + """) % (slide.vimeo_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_type == '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' + vimeo_match = re.search(self.VIMEO_VIDEO_ID_REGEX, slide.video_url) if slide.video_url else False + if not video_source_type and vimeo_match and len(vimeo_match.groups()) == 3: + video_source_type = 'vimeo' + + 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('video_url', 'video_source_type') + def _compute_vimeo_id(self): + for slide in self: + if slide.video_url and slide.video_source_type == 'vimeo': + match = re.search(self.VIMEO_VIDEO_ID_REGEX, slide.video_url) + if match and len(match.groups()) == 3: + if match.group(3): + # in case of privacy 'with URL only', vimeo adds a token after the video ID + # the share url is then 'vimeo_id/token' + # the token will be captured in the third group of the regex (if any) + slide.vimeo_id = '%s/%s' % (match.group(2), match.group(3)) + else: + # regular video, we just capture the vimeo_id + slide.vimeo_id = match.group(2) + else: + slide.vimeo_id = False - @api.onchange('url') + @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. """ + + 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. + We only populate the field that are empty to avoid overriding user assigned values. + The slide metadata are also fetched in create / write overrides to ensure consistency. """ - @api.onchange('datas') - def _on_change_datas(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_type is changed - into infographic and the uploaded dataS is transfered to the - image field. (It avoids the infinite loading in PDF viewer)""" - if self.datas: - data = base64.b64decode(self.datas) - 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_type = 'infographic' - self.image_1920 = self.datas - self.datas = 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({ + key: value + for key, value in slide_metadata.items() + if not self[key] + }) + + @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 +554,32 @@ class Slide(models.Model): # 'website_published' is handled by mixin values['date_published'] = False - if values.get('slide_type') == 'infographic' and not values.get('image_1920'): - values['image_1920'] = values['datas'] 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 +589,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 +903,297 @@ 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 == 'video' and self.video_source_type == 'vimeo': + slide_metadata, error = self._fetch_vimeo_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_type': '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_type(vals): - # TDE FIXME: WTF ?? - slide_type = '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_type + 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_type'] = 'video' - elif google_values['mimeType'].startswith('image/'): - values['datas'] = values['image_1920'] - values['slide_type'] = 'infographic' - elif google_values['mimeType'].startswith('application/vnd.google-apps'): - values['slide_type'] = get_slide_type(values) - if 'exportLinks' in google_values: - values['datas'] = 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['datas'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values'] - values['slide_type'] = get_slide_type(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 _fetch_vimeo_metadata(self, image_url_only=False): + """ Fetches video metadata from the Vimeo API. + See https://developer.vimeo.com/api/oembed/showcases for more information. + + 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 'fetch_image' param is False + - 'completion_time' matching the video duration + + :param image_url_only: if False, 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() + error_message = False + try: + response = requests.get( + 'https://vimeo.com/api/oembed.json?%s' % urls.url_encode({'url': self.video_url}), + timeout=3 + ) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + error_message = e.response.content + if e.response.status_code == 404: + return {}, _('Your video could not be found on Vimeo, please check the link and/or privacy settings') + except requests.exceptions.ConnectionError as e: + error_message = str(e) + + if not error_message and 'application/json' in response.headers.get('content-type'): + response = response.json() + if response.get('error'): + error_message = response.get('error', {}).get('errors', [{}])[0].get('reason') + + if not response: + error_message = _('Please enter a valid Vimeo video link') + + if error_message: + _logger.warning('Could not fetch Vimeo metadata: %s', error_message) + return {}, error_message + + vimeo_values = response + slide_metadata = {'slide_type': 'vimeo_video'} + + if vimeo_values.get('title'): + slide_metadata['name'] = vimeo_values.get('title') + + if vimeo_values.get('description'): + slide_metadata['description'] = vimeo_values.get('description') + + if vimeo_values.get('duration'): + # seconds to hours conversion + slide_metadata['completion_time'] = vimeo_values.get('duration') / (60 * 60) + + thumbnail_url = vimeo_values.get('thumbnail_url') + if thumbnail_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 + ) + + return slide_metadata, None def _default_website_meta(self): res = super(Slide, self)._default_website_meta() @@ -913,6 +1207,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 @@ -942,9 +1250,9 @@ class Slide(models.Model): } def _search_render_results(self, fetch_fields, mapping, icon, limit): - icon_per_type = { + icon_per_category = { 'infographic': 'fa-file-picture-o', - 'webpage': 'fa-file-text', + 'article': 'fa-file-text', 'presentation': 'fa-file-pdf-o', 'document': 'fa-file-pdf-o', 'video': 'fa-play-circle', @@ -953,7 +1261,7 @@ class Slide(models.Model): } results_data = super()._search_render_results(fetch_fields, mapping, icon, limit) for slide, data in zip(self, results_data): - data['_fa'] = icon_per_type.get(slide.slide_type, 'fa-file-pdf-o') + data['_fa'] = icon_per_category.get(slide.slide_category, 'fa-file-pdf-o') data['url'] = slide.website_url data['course'] = _('Course: %s', slide.channel_id.name) data['course_url'] = slide.channel_id.website_url diff --git a/addons/website_slides/static/src/js/slides_category_add.js b/addons/website_slides/static/src/js/slides_category_add.js index ac1f7e16ddc..dd14ca37f8a 100644 --- a/addons/website_slides/static/src/js/slides_category_add.js +++ b/addons/website_slides/static/src/js/slides_category_add.js @@ -19,7 +19,7 @@ var CategoryAddDialog = Dialog.extend({ classes: 'btn-primary', click: this._onClickFormSubmit.bind(this) }, { - text: _t('Discard'), + text: _t('Back'), close: true }] }); 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 e8cad60594f..15faec76716 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 @@ -1,9 +1,10 @@ /** @odoo-module **/ -/* global YT */ +/* global YT, Vimeo */ import publicWidget from 'web.public.widget'; import { qweb as QWeb, _t } from 'web.core'; + import { Markup } from 'web.utils'; import config from 'web.config'; import session from 'web.session'; @@ -30,8 +31,8 @@ * its end, and `slide_completed` when the player is at 30 sec before the * end of the video (30 sec before is considered as completed). */ - var VideoPlayer = publicWidget.Widget.extend({ - template: 'website.slides.fullscreen.video', + var VideoPlayerYouTube = publicWidget.Widget.extend({ + template: 'website.slides.fullscreen.video.youtube', youtubeUrl: 'https://www.youtube.com/iframe_api', init: function (parent, slide) { @@ -129,6 +130,98 @@ }, }); + /** + * This widget is responsible of loading the Vimeo video. + * + * Similarly to the YouTube implementation, the widget will trigger an event `change_slide` when + * the video is at its end, and `slide_completed` when the player is at 30 sec before the end of + * the video (30 sec before is considered as completed). + * + * See https://developer.vimeo.com/player/sdk/reference for all the API documentation. + */ + var VideoPlayerVimeo = publicWidget.Widget.extend({ + template: 'website.slides.fullscreen.video.vimeo', + vimeoScriptUrl: 'https://player.vimeo.com/api/player.js', + + init: function (parent, slide) { + this.slide = slide; + return this._super.apply(this, arguments); + }, + + /** + * Loads the Vimeo JS API that allows interfacing with the iframe viewer. + * (We only load the API if not already loaded). + * + * @returns {Promise} + */ + willStart: function () { + var self = this; + var vimeoAPIPromise = new Promise(function (resolve, reject) { + if ($(document).find('script[src="' + self.vimeoScriptUrl + '"]').length === 0) { + $.ajax({ + url: self.vimeoScriptUrl, + dataType: 'script', + success: function () {resolve();} + }); + } else { + resolve(); + } + }); + + return Promise.all([this._super.apply(this, arguments), vimeoAPIPromise]); + }, + + start: function () { + return this._super.apply(arguments).then(this._setupVideoPlayer.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Instantiate the Vimeo player and register the various events. + */ + _setupVideoPlayer: async function () { + this.player = new Vimeo.Player(this.$('iframe')[0]); + this.videoDuration = await this.player.getDuration(); + this.player.on('timeupdate', this._onVideoTimeUpdate.bind(this)); + this.player.on('ended', this._onVideoEnded.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When the player triggers the 'ended' event, we go to the next slide if there is one. + * + * See https://developer.vimeo.com/player/sdk/reference#ended for more information + */ + _onVideoEnded: function () { + if (this.slide.hasNext) { + this.trigger_up('slide_go_next', this.slide); + } + }, + + /** + * Every time the video changes position, both while viewing and also when seeking manually, + * Vimeo triggers this handy 'timeupdate' event. + * We use it to set the slide as completed as soon as we reach the end (30 last seconds). + * + * See https://developer.vimeo.com/player/sdk/reference#timeupdate for more information + * + * @param {Object} eventData the 'timeupdate' event data + */ + _onVideoTimeUpdate: async function (eventData) { + if (eventData.seconds > (this.videoDuration - 30)) { + if (!this.slide.hasQuestion && !this.slide.completed){ + this.trigger_up('slide_to_complete', this.slide); + } + } + } + }); + /** * This widget is responsible of navigation for one slide to another: @@ -225,7 +318,7 @@ //-------------------------------------------------------------------------- /** * Handler called whenever the user clicks on a sub-quiz which is linked to a slide. - * This does NOT handle the case of a slide of type "quiz". + * This does NOT handle the case of a slide of category "quiz". * By going through this handler, the widget will be able to determine that it has to render * the associated quiz and not the main content. * @@ -455,7 +548,7 @@ // Private //-------------------------------------------------------------------------- /** - * Fetches content with an rpc call for slides of type "webpage" + * Fetches content with an rpc call for slides of category "article" * * @private */ @@ -474,14 +567,14 @@ }); }, /** - * Fetches slide content depending on its type. + * Fetches slide content depending on its category. * If the slide doesn't need to fetch any content, return a resolved deferred * * @private */ _fetchSlideContent: function (){ var slide = this.get('slide'); - if (slide.type === 'webpage' && !slide.isQuiz) { + if (slide.category === 'article' && !slide.isQuiz) { return this._fetchHtmlContent(); } return Promise.resolve(); @@ -494,14 +587,14 @@ }, /** * Extend the slide data list to add informations about rendering method, and other - * specific values according to their slide_type. + * specific values according to their slide_category. */ _preprocessSlideData: function (slidesDataList) { slidesDataList.forEach(function (slideData, index) { // compute hasNext slide slideData.hasNext = index < slidesDataList.length-1; // compute embed url - if (slideData.type === 'video') { + if (slideData.category === 'video' && slideData.videoSourceType !== 'vimeo') { slideData.embedCode = $(slideData.embedCode).attr('src') || ""; // embedCode contains an iframe tag, where src attribute is the url (youtube or embed document from odoo) var separator = slideData.embedCode.indexOf("?") !== -1 ? "&" : "?"; var scheme = slideData.embedCode.indexOf('//') === 0 ? 'https:' : ''; @@ -510,16 +603,26 @@ params.autoplay = 1; } slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : ""; - } else if (slideData.type === 'infographic') { + } else if (slideData.category === 'video' && slideData.videoSourceType === 'vimeo') { + slideData.embedCode = Markup(slideData.embedCode); + } else if (slideData.category === 'infographic') { slideData.embedUrl = _.str.sprintf('/web/image/slide.slide/%s/image_1024', slideData.id); - } else if (_.contains(['document', 'presentation'], slideData.type)) { + } 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.type) && !slideData.hasQuestion; + var autoSetDone = false; + if (!slideData.hasQuestion) { + if (_.contains(['infographic', 'document', 'article'], slideData.category)) { + autoSetDone = true; // images, documents (local + external) and articles 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; }, @@ -542,8 +645,8 @@ history.pushState(null, '', fullscreenUrl); }, /** - * Render the current slide content using specific mecanism according to slide type: - * - simply append content (for webpage) + * Render the current slide content using specific mecanism according to slide category: + * - simply append content (for article) * - template rendering (for image, document, ....) * - using a sub widget (quiz and video) * @@ -556,20 +659,25 @@ $content.empty(); // display quiz slide, or quiz attached to a slide - if (slide.type === 'quiz' || slide.isQuiz) { + if (slide.category === 'quiz' || slide.isQuiz) { $content.addClass('bg-white'); var QuizWidget = new Quiz(this, slide, this.channel); return QuizWidget.appendTo($content); } // render slide content - if (_.contains(['document', 'presentation', 'infographic'], slide.type)) { + if (_.contains(['document', 'infographic'], slide.category)) { $content.html(QWeb.render('website.slides.fullscreen.content', {widget: this})); - } else if (slide.type === 'video') { - this.videoPlayer = new VideoPlayer(this, slide); + } else if (slide.category === 'video' && slide.videoSourceType === 'youtube') { + this.videoPlayer = new VideoPlayerYouTube(this, slide); return this.videoPlayer.appendTo($content); - } else if (slide.type === 'webpage'){ - var $wpContainer = $('
').addClass('o_wslide_fs_webpage_content bg-white block w-100 overflow-auto'); + } else if (slide.category === 'video' && slide.videoSourceType === 'vimeo') { + this.videoPlayer = new VideoPlayerVimeo(this, slide); + return this.videoPlayer.appendTo($content); + } else if (slide.category === 'video' && slide.videoSourceType === 'google_drive') { + $content.html(QWeb.render('website.slides.fullscreen.video.google_drive', {widget: this})); + } else if (slide.category === 'article'){ + var $wpContainer = $('
').addClass('o_wslide_fs_article_content bg-white block w-100 overflow-auto'); $(slide.htmlContent).appendTo($wpContainer); $content.append($wpContainer); this.trigger_up('widgets_start_request', { @@ -608,7 +716,7 @@ * Triggered whenever the user changes slides. * When the current slide is changed, widget will be automatically updated * and allowed to: fetch the content if needed, render it, update the url, - * and set slide as "completed" according to its type requirements. In + * and set slide as "completed" according to its category requirements. In * mobile case (i.e. limited screensize), sidebar will be toggled since * sidebar will block most or all of new slide visibility. * @@ -627,7 +735,7 @@ return self._renderSlide(); }).then(function() { if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call - if (['document', 'presentation'].includes(slide.type)) { + 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_course_quiz.js b/addons/website_slides/static/src/js/slides_course_quiz.js index 1adaa2c440c..a02d907630d 100644 --- a/addons/website_slides/static/src/js/slides_course_quiz.js +++ b/addons/website_slides/static/src/js/slides_course_quiz.js @@ -18,7 +18,7 @@ * This widget is responsible of displaying quiz questions and propositions. Submitting the quiz will fetch the * correction and decorate the answers according to the result. Error message or modal can be displayed. * - * This widget can be attached to DOM rendered server-side by `website_slides.slide_type_quiz` or + * This widget can be attached to DOM rendered server-side by `website_slides.slide_category_quiz` or * used client side (Fullscreen). * * Triggered events are : diff --git a/addons/website_slides/static/src/js/slides_course_tag_add.js b/addons/website_slides/static/src/js/slides_course_tag_add.js index acd0d4904f2..032587838cf 100644 --- a/addons/website_slides/static/src/js/slides_course_tag_add.js +++ b/addons/website_slides/static/src/js/slides_course_tag_add.js @@ -25,7 +25,7 @@ var TagCourseDialog = Dialog.extend({ classes: 'btn-primary', click: this._onClickFormSubmit.bind(this) }, { - text: _t("Discard"), + text: _t("Back"), click: this._onClickClose.bind(this) }] }); diff --git a/addons/website_slides/static/src/js/slides_upload.js b/addons/website_slides/static/src/js/slides_upload.js index 6e488328124..f7f901c82cc 100644 --- a/addons/website_slides/static/src/js/slides_upload.js +++ b/addons/website_slides/static/src/js/slides_upload.js @@ -10,9 +10,12 @@ var SlideUploadDialog = Dialog.extend({ template: 'website.slide.upload.modal', events: _.extend({}, Dialog.prototype.events, { 'click .o_wslides_js_upload_install_button': '_onClickInstallModule', - 'click .o_wslides_select_type': '_onClickSlideTypeIcon', + '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("Add Content"), size: 'medium', }); this._super(parent, options); @@ -60,10 +63,10 @@ var SlideUploadDialog = Dialog.extend({ * @private * @param {string} message */ - _alertDisplay: function (message) { + _alertDisplay: function (message, alertClass='alert-warning') { this._alertRemove(); $('
', { - "class": 'alert alert-warning', + "class": 'alert ' + alertClass, id: 'upload-alert', role: 'alert' }).text(message).insertBefore(this.$('form')); @@ -99,17 +102,21 @@ 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 }, }); }, _formSetFieldValue: function (fieldId, value) { - this.$('form').find('#'+fieldId).val(value); + var $formField = this.$('form').find('#'+fieldId); + if (!$formField.val()) { // update only if the user did not assign a value manually + $formField.val(value); + } }, _formGetFieldValue: function (fieldId) { return this.$('#'+fieldId).val(); @@ -120,52 +127,53 @@ var SlideUploadDialog = Dialog.extend({ return form[0].checkValidity() && this.isValidUrl; }, /** - * Extract values to submit from form, force the slide_type according to + * Extract values to submit from form, force the slide_category according to * filled values. * * @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 - - // default slide_type (for webpage for instance) - if (_.contains(this.slide_type_data), this.get('state')) { - values['slide_type'] = this.get('state'); + var slideCategory = 'document'; + // default slide_category (for article for instance) + if (_.contains(this.slide_category_data), 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_type': canvas.height > canvas.width ? 'document' : 'presentation', - 'mime_type': this.file.type, - 'datas': this.file.data + 'slide_category': 'document', + 'binary_content': this.file.data }); - } else if (values['slide_type'] === 'webpage') { + } else if (values['slide_category'] === 'article') { _.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_type'] === 'presentation') { - _.extend(values, { - 'slide_type': 'infographic', - 'mime_type': this.file.type === 'image/svg+xml' ? 'image/png' : this.file.type, - 'datas': 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; }, @@ -187,7 +195,7 @@ var SlideUploadDialog = Dialog.extend({ if (! this.modulesToInstallStatus.installing) { btnList.push({text: this.modulesToInstallStatus.failed ? _t("Retry") : _t("Install"), classes: 'btn-primary', click: this._onClickInstallModuleConfirm.bind(this)}); } - btnList.push({text: _t("Discard"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)}); + btnList.push({text: _t("Back"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)}); } else if (state !== '_upload') { // no button when uploading if (this.canUpload) { if (this.canPublish) { @@ -197,7 +205,7 @@ var SlideUploadDialog = Dialog.extend({ btnList.push({text: _t("Save"), classes: 'btn-primary o_w_slide_upload', click: this._onClickFormSubmit.bind(this)}); } } - btnList.push({text: _t("Discard"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)}); + btnList.push({text: _t("Back"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)}); } return btnList; }, @@ -321,24 +329,29 @@ var SlideUploadDialog = Dialog.extend({ return values; }, /** - * Init the data relative to the support slide type to upload + * Init the data relative to the support slide category to upload * * @private */ _setup: function () { - this.slide_type_data = { - presentation: { + this.slide_category_data = { + document: { icon: 'fa-file-pdf-o', - label: _t('Presentation'), - template: 'website.slide.upload.modal.presentation', + label: _t('Document'), + template: 'website.slide.upload.modal.document', }, - webpage: { + infographic: { + icon: 'fa-file-image-o', + label: _t('Image'), + template: 'website.slide.upload.modal.infographic', + }, + article: { icon: 'fa-file-text', - label: _t('Web Page'), - template: 'website.slide.upload.modal.webpage', + label: _t('Article'), + template: 'website.slide.upload.modal.article', }, video: { - icon: 'fa-video-camera', + icon: 'fa-file-video-o', label: _t('Video'), template: 'website.slide.upload.modal.video', }, @@ -378,6 +391,80 @@ 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 { + if (data.info) { + self._alertDisplay(data.info, 'alert-info'); + } else { + self._alertRemove(); + } + + self.isValidUrl = true; + + if (data.name) { + self._formSetFieldValue('name', data.name); + self.$('#slide-video-title') + .text(data.name) + .removeClass('d-none'); + } else { + self.$('#slide-video-title').addClass('d-none'); + } + + 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 //-------------------------------------------------------------------------- @@ -393,7 +480,7 @@ var SlideUploadDialog = Dialog.extend({ } else if (currentType === '_import') { tmpl = 'website.slide.upload.modal.import'; } else { - tmpl = this.slide_type_data[currentType]['template']; + tmpl = this.slide_category_data[currentType]['template']; this.$modal.find('.modal-dialog').addClass('modal-lg'); } this.$('.o_w_slide_upload_modal_container').empty(); @@ -404,7 +491,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("Add Content")); } }, _onChangeCanSubmitForm: function (ev) { @@ -414,6 +501,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 +628,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'); @@ -623,10 +716,10 @@ var SlideUploadDialog = Dialog.extend({ } }, - _onClickSlideTypeIcon: function (ev) { + _onClickSlideCategoryIcon: function (ev) { var $elem = this.$(ev.currentTarget); - var slideType = $elem.data('slideType'); - this.set('state', slideType); + var slideCategory = $elem.data('slideCategory'); + this.set('state', slideCategory); this._bindSelect2Dropdown(); // rebind select2 at each modal body rendering }, 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 83b40c98b3c..b31ca9acd55 100644 --- a/addons/website_slides/static/src/js/tours/slides_tour.js +++ b/addons/website_slides/static/src/js/tours/slides_tour.js @@ -42,11 +42,11 @@ tour.register('slides_tour', { width: 260, }, { trigger: 'a.btn-primary.o_wslides_js_slide_upload', - 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.")), + 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 an article or link a video.")), position: 'bottom', }, { - trigger: 'a[data-slide-type="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/tests/tours/slides_tour_tools.js b/addons/website_slides/static/src/tests/tours/slides_tour_tools.js index 1b891a9a476..bc579154516 100644 --- a/addons/website_slides/static/src/tests/tours/slides_tour_tools.js +++ b/addons/website_slides/static/src/tests/tours/slides_tour_tools.js @@ -29,7 +29,7 @@ var addVideoToSection = function (sectionName, saveAsDraft) { trigger: 'div.o_wslides_slide_list_category_header:contains("' + sectionName + '") a:contains("Add Content")', }, { content: 'eLearning: click on video', - trigger: 'a[data-slide-type=video]', + trigger: 'a[data-slide-category=video]', }, { content: 'eLearning: fill video link', trigger: 'input[name=url]', @@ -56,16 +56,16 @@ var addVideoToSection = function (sectionName, saveAsDraft) { return base_steps; }; -var addWebPageToSection = function (sectionName, pageName) { +var addArticleToSection = function (sectionName, pageName) { return [ { content: 'eLearning: add content to section', trigger: 'div.o_wslides_slide_list_category_header:contains("' + sectionName + '") a:contains("Add Content")', }, { - content: 'eLearning: click on webpage', - trigger: 'a[data-slide-type=webpage]', + content: 'eLearning: click on article', + trigger: 'a[data-slide-category=article]', }, { - content: 'eLearning: fill webpage title', + content: 'eLearning: fill article title', trigger: 'input[name=name]', run: 'text ' + pageName, }, { @@ -76,7 +76,7 @@ var addWebPageToSection = function (sectionName, pageName) { trigger: 'div.select2-result-label:contains("Theory")', in_modal: false, }, { - content: 'eLearning: fill webpage completion time', + content: 'eLearning: fill article completion time', trigger: 'input[name=duration]', run: 'text 4', }]; @@ -132,7 +132,7 @@ var addNewCourseTag = function (courseTagName) { export default { addSection: addSection, addVideoToSection: addVideoToSection, - addWebPageToSection: addWebPageToSection, + addArticleToSection: addArticleToSection, addExistingCourseTag: addExistingCourseTag, addNewCourseTag: addNewCourseTag, }; 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 ceebb9d246c..a4415789b97 100644 --- a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml +++ b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml @@ -1,22 +1,34 @@ - +
+
+
+ +
+ +
+ +
+
+
diff --git a/addons/website_slides/static/src/xml/website_slides_share.xml b/addons/website_slides/static/src/xml/website_slides_share.xml index 6f7792fbf33..b50d9e6617b 100644 --- a/addons/website_slides/static/src/xml/website_slides_share.xml +++ b/addons/website_slides/static/src/xml/website_slides_share.xml @@ -52,7 +52,7 @@
-

Please login to share this by email.

+

Please login to share this by email.

diff --git a/addons/website_slides/static/src/xml/website_slides_upload.xml b/addons/website_slides/static/src/xml/website_slides_upload.xml index a71c05689d5..f5874a0fa5b 100644 --- a/addons/website_slides/static/src/xml/website_slides_upload.xml +++ b/addons/website_slides/static/src/xml/website_slides_upload.xml @@ -10,17 +10,17 @@
- @@ -55,7 +55,7 @@
@@ -71,9 +71,9 @@
- +
- +
Minutes
@@ -82,16 +82,36 @@ - +
- - +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
@@ -99,15 +119,39 @@
-
How to upload your PowerPoint Presentations or Word Documents?
-
Save your presentations or documents as PDF files and upload them.
- -
- Content Preview +
+ Content Preview
@@ -116,7 +160,60 @@
- + +
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + +
+
+
+
+
How do I add new content?
+
+ You can either upload a file from your computer or insert a Google Drive link.
+ The Google Drive link can be obtained by using the 'share' button in the Google interface.
+ It should look similar to + https://drive.google.com/file/d/ABC/view?usp=sharing +
+
+
+ Content Preview +
+
+
+
+ +
+
+ +
@@ -127,8 +224,8 @@
-
How to create a Lesson as a Web Page?
-
First, create your lesson, then edit it with the website builder. You'll be able to drop building blocks on your page and edit them.
+
How to create a Lesson as an Article?
+
First, create your lesson, then edit it with the website builder. You'll be able to drop building blocks on your page and edit them.
@@ -143,8 +240,9 @@
- - + +
@@ -153,12 +251,35 @@
How to upload your videos ?
-
First, upload your videos on YouTube and mark them as unlisted. This way, they will be secured.
-
What does unlisted means? The YouTube "unlisted" means it is a video which can be viewed only by the users with the link to it. Your video will never come up in the search results nor on your channel.
- +
On YouTube
+
First, upload your videos on YouTube and mark them as unlisted. This way, they will be secured.
+
What does unlisted means? The YouTube "unlisted" means it is a video which can be viewed only by the users with the link to it. Your video will never come up in the search results nor on your channel.
+ +
+
On Vimeo
+
+ First, upload your videos on Vimeo and mark them as Private. This way, they will be secured.
+ What does Private mean? The Vimeo "Private" privacy setting means it is a video which can be viewed only by the users with the link to it. + Your video will never come up in the search results nor on your channel.
+ Change video privacy settings

+ The video link to input here can be obtained by using the 'share' button in the Vimeo interface.
+ It should look similar to + https://vimeo.com/558907333/30da9ff3d8 + for 'Private' videos and similar to + https://vimeo.com/558907555 + for public ones. +
+
+
On Google Drive
+
+ The Google Drive link can be obtained by using the 'share' button in the Google interface.
+ It should look similar to + https://drive.google.com/file/d/ABC/view?usp=sharing +
-
- Content Preview +
+ Content Preview +
@@ -179,7 +300,7 @@
Test your students with small Quizzes
-
With Quizzes you can keep your students focused and motivated by answering some questions and gaining some karma points
+
With Quizzes you can keep your students focused and motivated by answering some questions and gaining some karma points
@@ -188,4 +309,14 @@
+ + + +
+

+ + Loading content... +

+
+
diff --git a/addons/website_slides/tests/common.py b/addons/website_slides/tests/common.py index c4ebce8d01e..73111d1eda1 100644 --- a/addons/website_slides/tests/common.py +++ b/addons/website_slides/tests/common.py @@ -55,7 +55,7 @@ class SlidesCase(common.TransactionCase): cls.slide = cls.env['slide.slide'].with_user(cls.user_officer).create({ 'name': 'How To Cook Humans', 'channel_id': cls.channel.id, - 'slide_type': 'presentation', + 'slide_category': 'document', 'is_published': True, 'completion_time': 2.0, 'sequence': 1, @@ -70,7 +70,7 @@ class SlidesCase(common.TransactionCase): cls.slide_2 = cls.env['slide.slide'].with_user(cls.user_officer).create({ 'name': 'How To Cook For Humans', 'channel_id': cls.channel.id, - 'slide_type': 'presentation', + 'slide_category': 'document', 'is_published': True, 'completion_time': 3.0, 'sequence': 3, @@ -78,7 +78,7 @@ class SlidesCase(common.TransactionCase): cls.slide_3 = cls.env['slide.slide'].with_user(cls.user_officer).create({ 'name': 'How To Cook Humans For Humans', 'channel_id': cls.channel.id, - 'slide_type': 'document', + 'slide_category': 'document', 'is_published': True, 'completion_time': 1.5, 'sequence': 4, diff --git a/addons/website_slides/tests/test_karma.py b/addons/website_slides/tests/test_karma.py index 83e8374bec0..a9453da8caf 100644 --- a/addons/website_slides/tests/test_karma.py +++ b/addons/website_slides/tests/test_karma.py @@ -28,14 +28,14 @@ class TestKarmaGain(common.SlidesCase): self.slide_2_0 = self.env['slide.slide'].with_user(self.user_officer).create({ 'name': 'How to travel through space and time', 'channel_id': self.channel_2.id, - 'slide_type': 'presentation', + 'slide_category': 'document', 'is_published': True, 'completion_time': 2.0, }) self.slide_2_1 = self.env['slide.slide'].with_user(self.user_officer).create({ 'name': 'How to duplicate yourself', 'channel_id': self.channel_2.id, - 'slide_type': 'presentation', + 'slide_category': 'document', 'is_published': True, 'completion_time': 2.0, }) diff --git a/addons/website_slides/tests/test_slide_utils.py b/addons/website_slides/tests/test_slide_utils.py index aa82e1abb65..e46130a61e1 100644 --- a/addons/website_slides/tests/test_slide_utils.py +++ b/addons/website_slides/tests/test_slide_utils.py @@ -168,8 +168,8 @@ class TestSequencing(slides_common.SlidesCase): class TestFromURL(slides_common.SlidesCase): - def test_youtube_urls(self): - urls = { + def test_video_youtube(self): + youtube_urls = { 'W0JQcpGLSFw': [ 'https://youtu.be/W0JQcpGLSFw', 'https://www.youtube.com/watch?v=W0JQcpGLSFw', @@ -188,9 +188,75 @@ class TestFromURL(slides_common.SlidesCase): ], } - for id, urls in urls.items(): + Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True) + + # test various YouTube URL formats + for youtube_id, urls in youtube_urls.items(): for url in urls: - with self.subTest(url=url, id=id): - document = self.env['slide.slide']._find_document_data_from_url(url) - self.assertEqual(document[0], 'youtube') - self.assertEqual(document[1], id) + with self.subTest(url=url, id=youtube_id): + slide = Slide.create({ + 'name': 'dummy', + 'channel_id': self.channel.id, + 'url': url, + 'slide_category': 'video' + }) + self.assertEqual('youtube', slide.video_source_type) + self.assertEqual(youtube_id, slide.youtube_id) + + def test_video_google_drive(self): + google_drive_urls = { + '1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR': [ + 'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing', + 'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR', + ], + } + + Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True) + + # test various Google Drive URL formats + for google_drive_id, urls in google_drive_urls.items(): + for url in urls: + with self.subTest(url=url, id=google_drive_id): + slide = Slide.create({ + 'name': 'dummy', + 'channel_id': self.channel.id, + 'url': url, + 'slide_category': 'video' + }) + self.assertEqual('google_drive', slide.video_source_type) + self.assertEqual(google_drive_id, slide.google_drive_id) + + def test_video_vimeo(self): + vimeo_urls = { + # regular URL from Vimeo + '545859999': [ + 'https://vimeo.com/545859999', + 'https://vimeo.com/545859999?autoplay=1', + ], + # test channel URL from Vimeo + '551979139': [ + 'https://vimeo.com/channels/staffpicks/551979139', + 'https://vimeo.com/channels/staffpicks/551979139?autoplay=1', + ], + # test URL from Vimeo with setting 'with URL only' + # we need to store both the ID and the token, see '_compute_embed_code' method for details + '545859999/94dd03ddb0': [ + 'https://vimeo.com/545859999/94dd03ddb0', + 'https://vimeo.com/545859999/94dd03ddb0?autoplay=1', + ], + } + + Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True) + + # test various Vimeo URL formats + for vimeo_id, urls in vimeo_urls.items(): + for url in urls: + with self.subTest(url=url, id=vimeo_id): + slide = Slide.create({ + 'name': 'dummy', + 'channel_id': self.channel.id, + 'url': url, + 'slide_category': 'video' + }) + self.assertEqual('vimeo', slide.video_source_type) + self.assertEqual(vimeo_id, slide.vimeo_id) diff --git a/addons/website_slides/tests/test_statistics.py b/addons/website_slides/tests/test_statistics.py index 79f0745aa2c..a570530ee84 100644 --- a/addons/website_slides/tests/test_statistics.py +++ b/addons/website_slides/tests/test_statistics.py @@ -39,12 +39,11 @@ class TestChannelStatistics(common.SlidesCase): @mute_logger('odoo.models') def test_channel_statistics(self): channel_publisher = self.channel.with_user(self.user_officer) - # slide type computation + # slide category computation self.assertEqual(channel_publisher.total_slides, len(channel_publisher.slide_content_ids)) - self.assertEqual(channel_publisher.nbr_infographic, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_type == 'infographic'))) - self.assertEqual(channel_publisher.nbr_presentation, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_type == 'presentation'))) - self.assertEqual(channel_publisher.nbr_document, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_type == 'document'))) - self.assertEqual(channel_publisher.nbr_video, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_type == 'video'))) + self.assertEqual(channel_publisher.nbr_infographic, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_category == 'infographic'))) + self.assertEqual(channel_publisher.nbr_document, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_category == 'document'))) + self.assertEqual(channel_publisher.nbr_video, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_category == 'video'))) # slide statistics computation self.assertEqual(float_compare(channel_publisher.total_time, sum(s.completion_time for s in channel_publisher.slide_content_ids), 3), 0) # members computation @@ -156,14 +155,11 @@ class TestSlideStatistics(common.SlidesCase): self.assertEqual(slide_emp.total_views, 5) @users('user_officer') - def test_slide_statistics_types(self): + def test_slide_statistics_categories(self): category = self.category.with_user(self.env.user) - self.assertEqual( - category.nbr_presentation, - len(category.channel_id.slide_ids.filtered(lambda s: s.category_id == category and s.slide_type == 'presentation'))) self.assertEqual( category.nbr_document, - len(category.channel_id.slide_ids.filtered(lambda s: s.category_id == category and s.slide_type == 'document'))) + len(category.channel_id.slide_ids.filtered(lambda s: s.category_id == category and s.slide_category == 'document'))) self.assertEqual(self.channel.total_slides, 3, 'The channel should contain 3 slides') self.assertEqual(category.total_slides, 2, 'The first category should contain 2 slides') diff --git a/addons/website_slides/tests/test_ui_wslides.py b/addons/website_slides/tests/test_ui_wslides.py index 96f907883c2..bc5fa4bf21f 100644 --- a/addons/website_slides/tests/test_ui_wslides.py +++ b/addons/website_slides/tests/test_ui_wslides.py @@ -33,32 +33,32 @@ class TestUICommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal): (0, 0, { 'name': 'Gardening: The Know-How', 'sequence': 1, - 'datas': pdf_content, - 'slide_type': 'presentation', + 'binary_content': pdf_content, + 'slide_category': 'document', 'is_published': True, 'is_preview': True, }), (0, 0, { 'name': 'Home Gardening', 'sequence': 2, 'image_1920': img_content, - 'slide_type': 'infographic', + 'slide_category': 'infographic', 'is_published': True, }), (0, 0, { 'name': 'Mighty Carrots', 'sequence': 3, 'image_1920': img_content, - 'slide_type': 'infographic', + 'slide_category': 'infographic', 'is_published': True, }), (0, 0, { 'name': 'How to Grow and Harvest The Best Strawberries | Basics', 'sequence': 4, - 'datas': pdf_content, - 'slide_type': 'document', + 'binary_content': pdf_content, + 'slide_category': 'document', 'is_published': True, }), (0, 0, { 'name': 'Test your knowledge', 'sequence': 5, - 'slide_type': 'quiz', + 'slide_category': 'quiz', 'is_published': True, 'question_ids': [ (0, 0, { diff --git a/addons/website_slides/views/slide_channel_views.xml b/addons/website_slides/views/slide_channel_views.xml index 79e7384cb99..4d3f23cbd51 100644 --- a/addons/website_slides/views/slide_channel_views.xml +++ b/addons/website_slides/views/slide_channel_views.xml @@ -77,9 +77,9 @@ - - - + + + diff --git a/addons/website_slides/views/slide_slide_views.xml b/addons/website_slides/views/slide_slide_views.xml index 04290773296..3f8affcb44d 100644 --- a/addons/website_slides/views/slide_slide_views.xml +++ b/addons/website_slides/views/slide_slide_views.xml @@ -116,14 +116,40 @@ - - - - - + + +
+ You can add questions to this quiz in the 'Quiz' tab. +
+
@@ -133,7 +159,7 @@ hours
- + @@ -211,7 +237,7 @@ sample="1"> - + @@ -242,20 +268,20 @@
- + - - + + - + - + - + @@ -324,7 +350,7 @@ - + @@ -336,7 +362,7 @@ - + diff --git a/addons/website_slides/views/website_slides_templates_course.xml b/addons/website_slides/views/website_slides_templates_course.xml index e1597c63f1d..e753b91f439 100644 --- a/addons/website_slides/views/website_slides_templates_course.xml +++ b/addons/website_slides/views/website_slides_templates_course.xml @@ -13,7 +13,7 @@ Courses -
  • +
  • @@ -25,8 +25,8 @@
  • Uncategorized
  • -
  • - +
  • +
  • [ ('survey_scoring_success', '=', True), - ('slide_id.slide_type', '=', 'certification') + ('slide_id.slide_category', '=', 'certification') ] diff --git a/addons/website_slides_survey/data/slide_slide_demo.xml b/addons/website_slides_survey/data/slide_slide_demo.xml index c8e1857370b..4a814ffc0b7 100644 --- a/addons/website_slides_survey/data/slide_slide_demo.xml +++ b/addons/website_slides_survey/data/slide_slide_demo.xml @@ -7,7 +7,7 @@ Furniture Creation Certification 7 - certification + certification @@ -23,7 +23,7 @@ DIY Furniture Certification 1 - certification + certification diff --git a/addons/website_slides_survey/models/slide_slide.py b/addons/website_slides_survey/models/slide_slide.py index 61afd804ccb..882c176158b 100644 --- a/addons/website_slides_survey/models/slide_slide.py +++ b/addons/website_slides_survey/models/slide_slide.py @@ -30,27 +30,45 @@ class SlidePartnerRelation(models.Model): class Slide(models.Model): _inherit = 'slide.slide' - slide_type = fields.Selection(selection_add=[ + slide_category = fields.Selection(selection_add=[ ('certification', 'Certification') ], ondelete={'certification': 'set default'}) + slide_type = fields.Selection(selection_add=[ + ('certification', 'Certification') + ], ondelete={'certification': 'set null'}) survey_id = fields.Many2one('survey.survey', 'Certification') nbr_certification = fields.Integer("Number of Certifications", compute='_compute_slides_statistics', store=True) + # small override of 'is_preview' to uncheck it automatically for slides of type 'certification' + is_preview = fields.Boolean(compute='_compute_is_preview', readonly=False, store=True) _sql_constraints = [ - ('check_survey_id', "CHECK(slide_type != 'certification' OR survey_id IS NOT NULL)", "A slide of type 'certification' requires a certification."), - ('check_certification_preview', "CHECK(slide_type != 'certification' OR is_preview = False)", "A slide of type certification cannot be previewed."), + ('check_survey_id', "CHECK(slide_category != 'certification' OR survey_id IS NOT NULL)", "A slide of type 'certification' requires a certification."), + ('check_certification_preview', "CHECK(slide_category != 'certification' OR is_preview = False)", "A slide of type certification cannot be previewed."), ] + @api.depends('slide_category') + def _compute_is_preview(self): + for slide in self: + if slide.slide_category == 'certification' or not slide.is_preview: + slide.is_preview = False + @api.onchange('survey_id') def _on_change_survey_id(self): if self.survey_id: - self.slide_type = 'certification' + self.slide_category = 'certification' + + @api.depends('slide_category', 'source_type') + def _compute_slide_type(self): + super(Slide, self)._compute_slide_type() + for slide in self: + if slide.slide_category == 'certification': + slide.slide_type = 'certification' @api.model def create(self, values): rec = super(Slide, self).create(values) if rec.survey_id: - rec.slide_type = 'certification' + rec.slide_category = 'certification' if 'survey_id' in values: rec._ensure_challenge_category() return rec @@ -90,7 +108,7 @@ class Slide(models.Model): course can be enrolled multiple times. """ certification_urls = {} - for slide in self.filtered(lambda slide: slide.slide_type == 'certification' and slide.survey_id): + for slide in self.filtered(lambda slide: slide.slide_category == 'certification' and slide.survey_id): if slide.channel_id.is_member: user_membership_id_sudo = slide.user_membership_id.sudo() if user_membership_id_sudo.user_input_ids: diff --git a/addons/website_slides_survey/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides_survey/static/src/js/slides_course_fullscreen_player.js index 0f1c9da26a1..05d6e7d89a0 100644 --- a/addons/website_slides_survey/static/src/js/slides_course_fullscreen_player.js +++ b/addons/website_slides_survey/static/src/js/slides_course_fullscreen_player.js @@ -11,7 +11,7 @@ Fullscreen.include({ ), /** - * Extend the _renderSlide method so that slides of type "certification" + * Extend the _renderSlide method so that slides of category "certification" * are also taken into account and rendered correctly * * @private @@ -20,7 +20,7 @@ Fullscreen.include({ _renderSlide: function (){ var def = this._super.apply(this, arguments); var $content = this.$('.o_wslides_fs_content'); - if (this.get('slide').type === "certification"){ + if (this.get('slide').category === "certification"){ $content.html(QWeb.render('website.slides.fullscreen.certification',{widget: this})); } return Promise.all([def]); diff --git a/addons/website_slides_survey/static/src/js/slides_upload.js b/addons/website_slides_survey/static/src/js/slides_upload.js index d6a88f780e9..2f1d57e50bc 100644 --- a/addons/website_slides_survey/static/src/js/slides_upload.js +++ b/addons/website_slides_survey/static/src/js/slides_upload.js @@ -7,7 +7,7 @@ var sessionStorage = window.sessionStorage; var SlidesUpload = require('@website_slides/js/slides_upload')[Symbol.for("default")]; /** - * Management of the new 'certification' slide_type + * Management of the new 'certification' slide_category */ SlidesUpload.SlideUploadDialog.include({ events: _.extend({}, SlidesUpload.SlideUploadDialog.prototype.events || {}, { @@ -32,14 +32,14 @@ SlidesUpload.SlideUploadDialog.include({ //-------------------------------------------------------------------------- /** - * Overridden to add the "certification" slide type + * Overridden to add the "certification" slide category * * @override * @private */ _setup: function () { this._super.apply(this, arguments); - this.slide_type_data['certification'] = { + this.slide_category_data['certification'] = { icon: 'fa-trophy', label: _t('Certification'), template: 'website.slide.upload.modal.certification', diff --git a/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml b/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml index 7ca796f6c35..ed7224295d4 100644 --- a/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml +++ b/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml @@ -3,14 +3,14 @@
    -
    + -
    +
    Download certification @@ -20,7 +20,7 @@ - + diff --git a/addons/website_slides_survey/tests/test_course_certification_failure.py b/addons/website_slides_survey/tests/test_course_certification_failure.py index 749e2d74e0e..72c88c6e9f3 100644 --- a/addons/website_slides_survey/tests/test_course_certification_failure.py +++ b/addons/website_slides_survey/tests/test_course_certification_failure.py @@ -47,11 +47,11 @@ class TestCourseCertificationFailureFlow(TestSurveyCommon): 'is_published': True, }) - # Step 2: link the certification to a slide of type 'certification' + # Step 2: link the certification to a slide of category 'certification' self.slide_certification = self.env['slide.slide'].sudo().create({ 'name': 'Certification slide', 'channel_id': self.channel.id, - 'slide_type': 'certification', + 'slide_category': 'certification', 'survey_id': certification.id, 'is_published': True, }) diff --git a/addons/website_slides_survey/views/slide_channel_views.xml b/addons/website_slides_survey/views/slide_channel_views.xml index 6baaef0139c..069e355be2d 100644 --- a/addons/website_slides_survey/views/slide_channel_views.xml +++ b/addons/website_slides_survey/views/slide_channel_views.xml @@ -10,11 +10,11 @@ Finished Certified - + - + diff --git a/addons/website_slides_survey/views/slide_slide_views.xml b/addons/website_slides_survey/views/slide_slide_views.xml index c3c013e2eed..ec9274771a4 100644 --- a/addons/website_slides_survey/views/slide_slide_views.xml +++ b/addons/website_slides_survey/views/slide_slide_views.xml @@ -5,11 +5,14 @@ slide.slide - + + + {'invisible': [('slide_category', '=', 'certification')]} + @@ -17,8 +20,8 @@ Certifications slide.slide tree,form,graph - [('slide_type', '=', 'certification')] - {'default_slide_type': 'certification'} + [('slide_category', '=', 'certification')] + {'default_slide_category': 'certification'}

    Add a new certification diff --git a/addons/website_slides_survey/views/website_profile.xml b/addons/website_slides_survey/views/website_profile.xml index 8d52bb0f189..23de1351193 100644 --- a/addons/website_slides_survey/views/website_profile.xml +++ b/addons/website_slides_survey/views/website_profile.xml @@ -45,7 +45,7 @@

    No certifications yet!
    diff --git a/addons/website_slides_survey/views/website_slides_templates_course.xml b/addons/website_slides_survey/views/website_slides_templates_course.xml index 7c4fa611032..4f00d2940f3 100644 --- a/addons/website_slides_survey/views/website_slides_templates_course.xml +++ b/addons/website_slides_survey/views/website_slides_templates_course.xml @@ -11,7 +11,7 @@
    Begin your certification today!
    - + Start Now
    @@ -26,12 +26,12 @@ diff --git a/addons/website_slides_survey/views/website_slides_templates_homepage.xml b/addons/website_slides_survey/views/website_slides_templates_homepage.xml index eaa1783346b..4c0875de564 100644 --- a/addons/website_slides_survey/views/website_slides_templates_homepage.xml +++ b/addons/website_slides_survey/views/website_slides_templates_homepage.xml @@ -3,7 +3,7 @@