From ab0b36e8a56fab151d029129e37dfd7d14d90c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Wed, 20 Oct 2021 12:27:04 +0000 Subject: [PATCH 1/5] [REF] website_slides: rename slide.slide fields This commit simply applies the following renames in the slide.slide model: - 'datas' is renamed into 'binary_content' - 'slide_type' is renamed into 'slide_category' This changes intend to ease the next bug refactoring of the website_slide module content management. There should be no functional changes applied in this commit. Task-2510174 Part-of: odoo/odoo#71477 --- .../tests/test_ui_wslides.py | 2 +- addons/website_slides/controllers/main.py | 59 +++++++----- .../data/mail_template_data.xml | 6 +- .../website_slides/data/slide_slide_demo.xml | 68 +++++++------- addons/website_slides/models/slide_channel.py | 36 ++++---- addons/website_slides/models/slide_slide.py | 92 +++++++++---------- .../src/js/slides_course_fullscreen_player.js | 32 +++---- .../static/src/js/slides_course_quiz.js | 2 +- .../static/src/js/slides_upload.js | 34 +++---- .../static/src/js/tours/slides_tour.js | 2 +- .../src/tests/tours/slides_tour_tools.js | 4 +- .../src/xml/website_slides_fullscreen.xml | 4 +- .../static/src/xml/website_slides_share.xml | 2 +- .../static/src/xml/website_slides_upload.xml | 18 ++-- addons/website_slides/tests/common.py | 6 +- addons/website_slides/tests/test_karma.py | 4 +- .../website_slides/tests/test_statistics.py | 16 ++-- .../website_slides/tests/test_ui_wslides.py | 14 +-- .../views/slide_channel_views.xml | 6 +- .../views/slide_slide_views.xml | 28 +++--- .../views/website_slides_templates_course.xml | 64 ++++++------- .../website_slides_templates_homepage.xml | 22 ++--- .../views/website_slides_templates_lesson.xml | 22 ++--- .../website_slides_templates_lesson_embed.xml | 8 +- ...ite_slides_templates_lesson_fullscreen.xml | 10 +- .../controllers/slides.py | 4 +- .../data/gamification_data.xml | 2 +- .../data/slide_slide_demo.xml | 4 +- .../models/slide_slide.py | 12 +-- .../src/js/slides_course_fullscreen_player.js | 4 +- .../static/src/js/slides_upload.js | 6 +- .../src/xml/website_slides_fullscreen.xml | 6 +- .../test_course_certification_failure.py | 4 +- .../views/slide_channel_views.xml | 4 +- .../views/slide_slide_views.xml | 8 +- .../views/website_profile.xml | 2 +- .../views/website_slides_templates_course.xml | 8 +- .../website_slides_templates_homepage.xml | 2 +- .../views/website_slides_templates_lesson.xml | 4 +- 39 files changed, 320 insertions(+), 311 deletions(-) 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/controllers/main.py b/addons/website_slides/controllers/main.py index d3902452fe3..e52856146dc 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 @@ -1064,8 +1073,8 @@ class WebsiteSlides(WebsiteProfile): @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 +1126,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 not slide.slide_category == "webpage": redirect_url = "/slides/%s" % (slug(channel)) - if slide.slide_type == 'webpage': + if slide.slide_category == 'webpage': redirect_url += "?enable_editor=1" return { 'url': redirect_url, @@ -1129,8 +1138,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', 'tag_ids', 'slide_category', 'channel_id', 'is_preview', + 'mime_type', 'binary_content', 'description', 'image_1920', 'is_published'] @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..0873542073d 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 + presentation @@ -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 @@ -212,7 +212,7 @@ https://www.youtube.com/watch?v=QYmgrw0PgLU QYmgrw0PgLU - video + video @@ -226,7 +226,7 @@ A little chat with Harry Potted 6 - webpage + webpage
@@ -327,9 +327,9 @@ 3 Main Methodologies 6 - + - presentation + presentation @@ -345,7 +345,7 @@ https://www.youtube.com/watch?v=l0JZ25VvbwE l0JZ25VvbwE - video + video @@ -378,9 +378,9 @@ Main Trees Categories 1 - + - presentation + presentation @@ -464,7 +464,7 @@ A Mighty Forest from Ages 2 - webpage + webpage
@@ -513,7 +513,7 @@ https://www.youtube.com/watch?v=ebBez6bcSEc ebBez6bcSEc - video + video @@ -527,9 +527,9 @@ Wood Characteristics 5 - + - presentation + presentation @@ -561,7 +561,7 @@ Comparing Hardness of Wood Species 2 - infographic + infographic @@ -577,7 +577,7 @@ https://www.youtube.com/watch?v=PYr1rK8pS30 PYr1rK8pS30 - video + video @@ -609,7 +609,7 @@ Foreword 1 - webpage + webpage
@@ -656,7 +656,7 @@ https://www.youtube.com/watch?v=bvSe6r5BpaY bvSe6r5BpaY - video + video @@ -670,7 +670,7 @@ Drawing 1 11 - infographic + infographic @@ -684,7 +684,7 @@ Drawing 2 12 - infographic + infographic @@ -697,9 +697,9 @@ Presentation 13 - + - document + document @@ -739,9 +739,9 @@ Unforgettable Tools 1 - + - presentation + presentation @@ -769,7 +769,7 @@ https://www.youtube.com/watch?v=5WMqwTnZ-qs 5WMqwTnZ-qs - video + video @@ -784,7 +784,7 @@ https://www.youtube.com/watch?v=grrXe1QZNzQ grrXe1QZNzQ - video + video @@ -797,7 +797,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..111fd812ebf 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -300,18 +300,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 +320,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 +847,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 +865,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..d5bf601544d 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -118,23 +118,23 @@ 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([ + slide_category = fields.Selection([ ('infographic', 'Infographic'), ('webpage', 'Web Page'), ('presentation', 'Presentation'), ('document', 'Document'), ('video', 'Video'), ('quiz', "Quiz")], - string='Type', required=True, + string='Category', 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) + help="The document category will be set automatically based on the document URL and properties (e.g. height and width for presentation and document).") + binary_content = fields.Binary('File', attachment=True) url = fields.Char('Document URL', help="Youtube or Google Document URL") document_id = fields.Char('Document ID', help="Youtube or Google Document ID") slide_resource_ids = fields.One2many('slide.slide.resource', 'slide_id', string="Additional Resource for this slide") slide_resource_downloadable = fields.Boolean('Allow Download', default=True, help="Allow the user to download the content of the slide.") mime_type = fields.Char('Mime-type') - html_content = fields.Html("HTML Content", help="Custom HTML content for slides of type 'Web Page'.", translate=True, sanitize_attributes=False, sanitize_form=False) + html_content = fields.Html("HTML Content", help="Custom HTML content for slides of category 'Web Page'.", translate=True, sanitize_attributes=False, sanitize_form=False) # website website_id = fields.Many2one(related='channel_id.website_id', readonly=True) date_published = fields.Datetime('Publish Date', readonly=True, tracking=False) @@ -270,35 +270,35 @@ 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_partner_ids.partner_id') @@ -315,7 +315,7 @@ class Slide(models.Model): self.env['slide.slide.partner'] ) - @api.depends('document_id', 'slide_type', 'mime_type') + @api.depends('document_id', 'slide_category', 'mime_type') def _compute_embed_code(self): base_url = request and request.httprequest.url_root @@ -326,13 +326,13 @@ class Slide(models.Model): base_url = record.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']): + if record.binary_content and (not record.document_id or record.slide_category in ['document', 'presentation']): slide_url = base_url + url_for('/slides/embed/%s?page=1' % record.id) slide_url_external = base_url + url_for('/slides/embed_external/%s?page=1' % record.id) base_embed_code = Markup('') record.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: + elif record.slide_category == 'video' and record.document_id: if not record.mime_type: # embed youtube video query = urls.url_parse(record.url).query @@ -359,15 +359,15 @@ class Slide(models.Model): for key, value in values.items(): self[key] = value - @api.onchange('datas') - def _on_change_datas(self): + @api.onchange('binary_content') + def _on_change_binary_content(self): """ For PDFs, we assume that it takes 5 minutes to read a page. If the selected file is not a PDF, it is an image (You can - only upload PDF or Image file) then the slide_type is changed - into infographic and the uploaded dataS is transfered to the + only upload PDF or Image file) then the slide_category is changed + into infographic and the uploaded binary_content is transfered to the image field. (It avoids the infinite loading in PDF viewer)""" - if self.datas: - data = base64.b64decode(self.datas) + if self.binary_content: + data = base64.b64decode(self.binary_content) if data.startswith(b'%PDF-'): pdf = PyPDF2.PdfFileReader(io.BytesIO(data), overwriteWarnings=False, strict=False) try: @@ -376,9 +376,9 @@ class Slide(models.Model): return self.completion_time = (5 * len(pdf.pages)) / 60 else: - self.slide_type = 'infographic' - self.image_1920 = self.datas - self.datas = None + self.slide_category = 'infographic' + self.image_1920 = self.binary_content + self.binary_content = None @api.depends('name', 'channel_id.website_id.domain') def _compute_website_url(self): @@ -409,8 +409,8 @@ 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('slide_category') == 'infographic' and not values.get('image_1920'): + values['image_1920'] = values['binary_content'] if values.get('is_category'): values['is_preview'] = True values['is_published'] = True @@ -794,7 +794,7 @@ class Slide(models.Model): if fetch_res.get('error'): return {'error': self._extract_google_error_message(fetch_res.get('error'))} - values = {'slide_type': 'video', 'document_id': document_id} + values = {'slide_category': 'video', 'document_id': document_id} items = fetch_res['values'].get('items') if not items: return {'error': _('Please enter valid Youtube or Google Doc URL')} @@ -845,15 +845,15 @@ class Slide(models.Model): @api.model def _parse_google_document(self, document_id, only_preview_fields): - def get_slide_type(vals): + def get_slide_category(vals): # TDE FIXME: WTF ?? - slide_type = 'presentation' + slide_category = 'presentation' if vals.get('image_1920'): image = Image.open(io.BytesIO(base64.b64decode(vals['image_1920']))) width, height = image.size if height > width: return 'document' - return slide_type + return slide_category # 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 @@ -886,18 +886,18 @@ class Slide(models.Model): 'document_id': document_id, } if google_values['mimeType'].startswith('video/'): - values['slide_type'] = 'video' + values['slide_category'] = 'video' elif google_values['mimeType'].startswith('image/'): - values['datas'] = values['image_1920'] - values['slide_type'] = 'infographic' + values['binary_content'] = values['image_1920'] + values['slide_category'] = 'infographic' elif google_values['mimeType'].startswith('application/vnd.google-apps'): - values['slide_type'] = get_slide_type(values) + values['slide_category'] = get_slide_category(values) if 'exportLinks' in google_values: - values['datas'] = self._fetch_data(google_values['exportLinks']['application/pdf'], params, 'pdf')['values'] + values['binary_content'] = self._fetch_data(google_values['exportLinks']['application/pdf'], params, 'pdf')['values'] elif google_values['mimeType'] == 'application/pdf': # TODO: Google Drive PDF document doesn't provide plain text transcript - values['datas'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values'] - values['slide_type'] = get_slide_type(values) + values['binary_content'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values'] + values['slide_category'] = get_slide_category(values) return {'values': values} @@ -942,7 +942,7 @@ 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', 'presentation': 'fa-file-pdf-o', @@ -953,7 +953,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_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js index e8cad60594f..5e4974c0437 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 @@ -225,7 +225,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 +455,7 @@ // Private //-------------------------------------------------------------------------- /** - * Fetches content with an rpc call for slides of type "webpage" + * Fetches content with an rpc call for slides of category "webpage" * * @private */ @@ -474,14 +474,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 === 'webpage' && !slide.isQuiz) { return this._fetchHtmlContent(); } return Promise.resolve(); @@ -494,14 +494,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.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 +510,16 @@ params.autoplay = 1; } slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : ""; - } else if (slideData.type === 'infographic') { + } 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 (_.contains(['document', 'presentation'], slideData.category)) { 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; + slideData._autoSetDone = _.contains(['infographic', 'presentation', 'document', 'webpage'], slideData.category) && !slideData.hasQuestion; }); return slidesDataList; }, @@ -542,7 +542,7 @@ history.pushState(null, '', fullscreenUrl); }, /** - * Render the current slide content using specific mecanism according to slide type: + * Render the current slide content using specific mecanism according to slide category: * - simply append content (for webpage) * - template rendering (for image, document, ....) * - using a sub widget (quiz and video) @@ -556,19 +556,19 @@ $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', 'presentation', 'infographic'], slide.category)) { $content.html(QWeb.render('website.slides.fullscreen.content', {widget: this})); - } else if (slide.type === 'video') { + } else if (slide.category === 'video') { this.videoPlayer = new VideoPlayer(this, slide); return this.videoPlayer.appendTo($content); - } else if (slide.type === 'webpage'){ + } else if (slide.category === 'webpage'){ var $wpContainer = $('
').addClass('o_wslide_fs_webpage_content bg-white block w-100 overflow-auto'); $(slide.htmlContent).appendTo($wpContainer); $content.append($wpContainer); @@ -608,7 +608,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 +627,7 @@ return self._renderSlide(); }).then(function() { if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call - if (['document', 'presentation'].includes(slide.type)) { + if (['document', 'presentation'].includes(slide.category)) { // 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_upload.js b/addons/website_slides/static/src/js/slides_upload.js index 6e488328124..14c605ee323 100644 --- a/addons/website_slides/static/src/js/slides_upload.js +++ b/addons/website_slides/static/src/js/slides_upload.js @@ -10,7 +10,7 @@ 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', }), @@ -120,7 +120,7 @@ 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 @@ -136,30 +136,30 @@ var SlideUploadDialog = Dialog.extend({ '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'); + // default slide_category (for webpage for instance) + if (_.contains(this.slide_category_data), this.get('state')) { + values['slide_category'] = this.get('state'); } if (this.file.type === 'application/pdf') { _.extend(values, { 'image_1920': canvas.toDataURL().split(',')[1], - 'slide_type': canvas.height > canvas.width ? 'document' : 'presentation', + 'slide_category': canvas.height > canvas.width ? 'document' : 'presentation', 'mime_type': this.file.type, - 'datas': this.file.data + 'binary_content': this.file.data }); - } else if (values['slide_type'] === 'webpage') { + } else if (values['slide_category'] === 'webpage') { _.extend(values, { 'mime_type': 'text/html', 'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data, }); } 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') { + if (values['slide_category'] === 'presentation') { _.extend(values, { - 'slide_type': 'infographic', + 'slide_category': 'infographic', 'mime_type': this.file.type === 'image/svg+xml' ? 'image/png' : this.file.type, - 'datas': fileData, + 'binary_content': fileData, }); } else { _.extend(values, { @@ -321,12 +321,12 @@ 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 = { + this.slide_category_data = { presentation: { icon: 'fa-file-pdf-o', label: _t('Presentation'), @@ -393,7 +393,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(); @@ -623,10 +623,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..6d5396defcc 100644 --- a/addons/website_slides/static/src/js/tours/slides_tour.js +++ b/addons/website_slides/static/src/js/tours/slides_tour.js @@ -45,7 +45,7 @@ tour.register('slides_tour', { content: Markup(_t("Your first section is created, now it's time to add lessons to your course. Click on Add Content to upload a document, create a web page or link a video.")), position: 'bottom', }, { - trigger: 'a[data-slide-type="presentation"]', + trigger: 'a[data-slide-category="presentation"]', content: Markup(_t("First, let's add a Presentation. It can be a .pdf or an image.")), position: 'bottom', }, { 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..62f7f4b334d 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]', @@ -63,7 +63,7 @@ var addWebPageToSection = function (sectionName, pageName) { 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]', + trigger: 'a[data-slide-category=webpage]', }, { content: 'eLearning: fill webpage title', trigger: 'input[name=name]', 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..f22d109bfb2 100644 --- a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml +++ b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml @@ -1,12 +1,12 @@ - +
') % (slide.youtube_id, query_params) + elif slide.video_source_type == 'google_drive': + embed_code = Markup('') % (slide.google_drive_id) + elif slide.slide_category in ['infographic', 'document'] and slide.source_type == 'external' and slide.google_drive_id: + embed_code = Markup('') % (slide.google_drive_id) + elif slide.slide_category == 'document' and slide.source_type == 'local_file': + slide_url = base_url + url_for('/slides/embed/%s?page=1' % slide.id) + slide_url_external = base_url + url_for('/slides/embed_external/%s?page=1' % slide.id) base_embed_code = Markup('') - record.embed_code = base_embed_code % (slide_url, 315, 420) + embed_code = base_embed_code % (slide_url, 315, 420) embed_code_external = base_embed_code % (slide_url_external, 315, 420) - elif record.slide_category == 'video' and record.document_id: - if not record.mime_type: - # embed youtube video - query = urls.url_parse(record.url).query - query = query + '&theme=light' if query else 'theme=light' - record.embed_code = Markup('') % (record.document_id, query) + + slide.embed_code = embed_code + slide.embed_code_external = embed_code_external or embed_code + + @api.depends('video_url') + def _compute_video_source_type(self): + for slide in self: + video_source_type = False + youtube_match = re.match(self.YOUTUBE_VIDEO_ID_REGEX, slide.video_url) if slide.video_url else False + if youtube_match and len(youtube_match.groups()) == 2 and len(youtube_match.group(2)) == 11: + video_source_type = 'youtube' + if slide.video_url and not video_source_type and re.match(self.GOOGLE_DRIVE_DOCUMENT_ID_REGEX, slide.video_url): + video_source_type = 'google_drive' + + slide.video_source_type = video_source_type + + @api.depends('video_url', 'video_source_type') + def _compute_youtube_id(self): + for slide in self: + if slide.video_url and slide.video_source_type == 'youtube': + match = re.match(self.YOUTUBE_VIDEO_ID_REGEX, slide.video_url) + if match and len(match.groups()) == 2 and len(match.group(2)) == 11: + slide.youtube_id = match.group(2) else: - # embed google doc video - record.embed_code = Markup('') % (record.document_id) + slide.youtube_id = False else: - record.embed_code = False + slide.youtube_id = False - record.embed_code_external = embed_code_external or record.embed_code + @api.depends('url', 'document_google_url', 'image_google_url', 'video_url') + def _compute_google_drive_id(self): + """ Extracts the Google Drive ID from the url based on the slide category. """ - @api.onchange('url') + for slide in self: + url = slide.url or slide.document_google_url or slide.image_google_url or slide.video_url + google_drive_id = False + if url: + match = re.match(self.GOOGLE_DRIVE_DOCUMENT_ID_REGEX, url) + if match and len(match.groups()) == 2: + google_drive_id = match.group(2) + + slide.google_drive_id = google_drive_id + + @api.onchange('url', 'document_google_url', 'image_google_url', 'video_url') def _on_change_url(self): - self.ensure_one() - if self.url: - res = self._parse_document_url(self.url) - if res.get('error'): - raise UserError(res.get('error')) - values = res['values'] - if not values.get('document_id'): - raise UserError(_('Please enter valid Youtube or Google Doc URL')) - for key, value in values.items(): - self[key] = value + """ Keeping a 'onchange' because we want this behavior for the frontend. + Changing the document / video external URL will populate some metadata on the form view. + The slide metadata are also fetched in create / write overrides to ensure consistency. """ - @api.onchange('binary_content') - def _on_change_binary_content(self): - """ For PDFs, we assume that it takes 5 minutes to read a page. - If the selected file is not a PDF, it is an image (You can - only upload PDF or Image file) then the slide_category is changed - into infographic and the uploaded binary_content is transfered to the - image field. (It avoids the infinite loading in PDF viewer)""" - if self.binary_content: - data = base64.b64decode(self.binary_content) - if data.startswith(b'%PDF-'): - pdf = PyPDF2.PdfFileReader(io.BytesIO(data), overwriteWarnings=False, strict=False) - try: - pdf.getNumPages() - except PyPDF2.utils.PdfReadError: - return - self.completion_time = (5 * len(pdf.pages)) / 60 - else: - self.slide_category = 'infographic' - self.image_1920 = self.binary_content - self.binary_content = None + self.ensure_one() + if self.url or self.document_google_url or self.image_google_url or self.video_url: + slide_metadata, _error = self._fetch_external_metadata() + if slide_metadata: + self.update(slide_metadata) + + @api.onchange('document_binary_content') + def _on_change_document_binary_content(self): + if self.slide_category == 'document' and self.source_type == 'local_file' and self.document_binary_content: + completion_time = self._get_completion_time_pdf(base64.b64decode(self.document_binary_content)) + if completion_time: + self.completion_time = completion_time + + @api.onchange('slide_category') + def _on_change_slide_category(self): + """ Prevents mis-match when ones uploads an image and then a pdf without saving the form. """ + if self.slide_category != 'infographic' and self.image_binary_content: + self.image_binary_content = False + elif self.slide_category != 'document' and self.document_binary_content: + self.document_binary_content = False @api.depends('name', 'channel_id.website_id.domain') def _compute_website_url(self): @@ -409,29 +510,32 @@ class Slide(models.Model): # 'website_published' is handled by mixin values['date_published'] = False - if values.get('slide_category') == 'infographic' and not values.get('image_1920'): - values['image_1920'] = values['binary_content'] if values.get('is_category'): values['is_preview'] = True values['is_published'] = True if values.get('is_published') and not values.get('date_published'): values['date_published'] = datetime.datetime.now() - if values.get('url') and not values.get('document_id'): - doc_data = self._parse_document_url(values['url']).get('values', dict()) - for key, value in doc_data.items(): - values.setdefault(key, value) slide = super(Slide, self).create(values) + # avoid fetching external metadata when installing the module (i.e. for demo data) + # we also support a context key if you don't want to fetch the metadata when creating a slide + if any(values.get(url_param) for url_param in ['url', 'video_url', 'document_google_url', 'image_google_url']) \ + and not self.env.context.get('install_mode') \ + and not self.env.context.get('website_slides_skip_fetch_metadata'): + slide_metadata, _error = slide._fetch_external_metadata() + if slide_metadata: + # only update keys that are not set in the incoming values + slide.update({key: value for key, value in slide_metadata.items() if key not in values.keys()}) + + if not 'completion_time' not in values: + slide._on_change_document_binary_content() + if slide.is_published and not slide.is_category: slide._post_publication() return slide def write(self, values): - if values.get('url') and values['url'] != self.url: - doc_data = self._parse_document_url(values['url']).get('values', dict()) - for key, value in doc_data.items(): - values.setdefault(key, value) if values.get('is_category'): values['is_preview'] = True values['is_published'] = True @@ -441,6 +545,20 @@ class Slide(models.Model): self.date_published = datetime.datetime.now() self._post_publication() + # avoid fetching external metadata when installing the module (i.e. for demo data) + # we also support a context key if you don't want to fetch the metadata when modifying a slide + if any(values.get(url_param) for url_param in ['url', 'video_url', 'document_google_url', 'image_google_url']) \ + and not self.env.context.get('install_mode') \ + and not self.env.context.get('website_slides_skip_fetch_metadata'): + slide_metadata, _error = self._fetch_external_metadata() + if slide_metadata: + # only update keys that are not set in the incoming values and for which we don't have a value yet + self.update({ + key: value + for key, value in slide_metadata.items() + if key not in values.keys() and not any(slide[key] for slide in self) + }) + if 'is_published' in values or 'active' in values: # if the slide is published/unpublished, recompute the completion for the partners self.slide_partner_ids._set_completed_callback() @@ -741,165 +859,228 @@ class Slide(models.Model): # Parsing methods # -------------------------------------------------- - @api.model - def _fetch_data(self, base_url, params, content_type=False): - result = {'values': dict()} + def _fetch_external_metadata(self, image_url_only=False): + self.ensure_one() + + slide_metadata = {} + error = False + if self.slide_category == 'video' and self.video_source_type == 'youtube': + slide_metadata, error = self._fetch_youtube_metadata(image_url_only) + elif self.slide_category == 'video' and self.video_source_type == 'google_drive': + slide_metadata, error = self._fetch_google_drive_metadata(image_url_only) + elif self.slide_category in ['document', 'infographic'] and self.source_type == 'external': + # external documents & google drive videos share the same method currently + slide_metadata, error = self._fetch_google_drive_metadata(image_url_only) + + return slide_metadata, error + + def _fetch_youtube_metadata(self, image_url_only=False): + """ Fetches video metadata from the YouTube API. + + Returns a dict containing video metadata with the following keys (matching slide.slide fields): + - 'name' matching the video title + - 'description' matching the video description + - 'image_1920' binary data of the video thumbnail + OR 'image_url' containing an external link to the thumbnail when 'image_url_only' param is True + - 'completion_time' matching the video duration + The received duration is under a special format (e.g: PT1M21S15, meaning 1h 21m 15s). + + :param image_url_only: if True, will return 'image_url' instead of binary data + Typically used when displaying a slide preview to the end user. + :return a tuple (values, error) containing the values of the slide and a potential error + (e.g: 'Video could not be found') """ + + self.ensure_one() + google_app_key = self.env['website'].get_current_website().website_slide_google_app_key + error_message = False try: - response = requests.get(base_url, timeout=3, params=params) + response = requests.get( + 'https://www.googleapis.com/youtube/v3/videos', + timeout=3, + params={ + 'fields': 'items(id,snippet,contentDetails)', + 'id': self.youtube_id, + 'key': google_app_key, + 'part': 'snippet,contentDetails' + } + ) response.raise_for_status() - if content_type == 'json': - result['values'] = response.json() - elif content_type in ('image', 'pdf'): - result['values'] = base64.b64encode(response.content) - else: - result['values'] = response.content except requests.exceptions.HTTPError as e: - result['error'] = e.response.content + error_message = e.response.content + if 'application/json' in e.response.headers.get('content-type'): + json_response = e.response.json() + if json_response.get('error', {}).get('code') == 404: + return {}, _('Your video could not be found on YouTube, please check the link and/or privacy settings') except requests.exceptions.ConnectionError as e: - result['error'] = str(e) - return result + error_message = str(e) - def _find_document_data_from_url(self, url): - url_obj = urls.url_parse(url) - if url_obj.ascii_host == 'youtu.be': - return ('youtube', url_obj.path[1:] if url_obj.path else False) - elif url_obj.ascii_host in ('youtube.com', 'www.youtube.com', 'm.youtube.com', 'www.youtube-nocookie.com'): - v_query_value = url_obj.decode_query().get('v') - if v_query_value: - return ('youtube', v_query_value) - split_path = url_obj.path.split('/') - if len(split_path) >= 3 and split_path[1] in ('v', 'embed'): - return ('youtube', split_path[2]) + if not error_message: + response = response.json() + if response.get('error'): + error_message = response.get('error', {}).get('errors', [{}])[0].get('reason') - expr = re.compile(r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)') - arg = expr.match(url) - document_id = arg and arg.group(2) or False - if document_id: - return ('google', document_id) + if not response.get('items'): + error_message = _('Your video could not be found on YouTube, please check the link and/or privacy settings') - return (None, False) - - def _parse_document_url(self, url, only_preview_fields=False): - document_source, document_id = self._find_document_data_from_url(url) - if document_source and hasattr(self, '_parse_%s_document' % document_source): - return getattr(self, '_parse_%s_document' % document_source)(document_id, only_preview_fields) - return {'error': _('Unknown document')} - - def _parse_youtube_document(self, document_id, only_preview_fields): - """ If we receive a duration (YT video), we use it to determine the slide duration. - The received duration is under a special format (e.g: PT1M21S15, meaning 1h 21m 15s). """ - - key = self.env['website'].get_current_website().website_slide_google_app_key - fetch_res = self._fetch_data('https://www.googleapis.com/youtube/v3/videos', {'id': document_id, 'key': key, 'part': 'snippet,contentDetails', 'fields': 'items(id,snippet,contentDetails)'}, 'json') - if fetch_res.get('error'): - return {'error': self._extract_google_error_message(fetch_res.get('error'))} - - values = {'slide_category': 'video', 'document_id': document_id} - items = fetch_res['values'].get('items') - if not items: - return {'error': _('Please enter valid Youtube or Google Doc URL')} - youtube_values = items[0] + if error_message: + _logger.warning('Could not fetch YouTube metadata: %s', error_message) + return {}, error_message + slide_metadata = {'slide_type': 'youtube_video'} + youtube_values = response.get('items')[0] youtube_duration = youtube_values.get('contentDetails', {}).get('duration') if youtube_duration: parsed_duration = re.search(r'^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$', youtube_duration) if parsed_duration: - values['completion_time'] = (int(parsed_duration.group(1) or 0)) + \ - (int(parsed_duration.group(2) or 0) / 60) + \ - (int(parsed_duration.group(3) or 0) / 3600) + slide_metadata['completion_time'] = (int(parsed_duration.group(1) or 0)) + \ + (int(parsed_duration.group(2) or 0) / 60) + \ + (int(parsed_duration.group(3) or 0) / 3600) if youtube_values.get('snippet'): snippet = youtube_values['snippet'] - if only_preview_fields: - values.update({ - 'url_src': snippet['thumbnails']['high']['url'], - 'title': snippet['title'], - 'description': snippet['description'] - }) - - return values - - values.update({ + slide_metadata.update({ 'name': snippet['title'], - 'image_1920': self._fetch_data(snippet['thumbnails']['high']['url'], {}, 'image')['values'], 'description': snippet['description'], - 'mime_type': False, }) - return {'values': values} - def _extract_google_error_message(self, error): - """ - See here for Google error format - https://developers.google.com/drive/api/v3/handle-errors - """ - try: - error = json.loads(error) - error = (error.get('error', {}).get('errors', []) or [{}])[0].get('reason') - except json.decoder.JSONDecodeError: - error = str(error) + thumbnail_url = snippet['thumbnails']['high']['url'] + if image_url_only: + slide_metadata['image_url'] = thumbnail_url + else: + slide_metadata['image_1920'] = base64.b64encode( + requests.get(thumbnail_url, timeout=3).content + ) - if error == 'keyInvalid': - return _('Your Google API key is invalid, please update it in your settings.\nSettings > Website > Features > API Key') + return slide_metadata, None - return _('Could not fetch data from url. Document or access right not available:\n%s', error) + def _fetch_google_drive_metadata(self, image_url_only=False): + """ Fetches document / video metadata from the Google Drive API. - @api.model - def _parse_google_document(self, document_id, only_preview_fields): - def get_slide_category(vals): - # TDE FIXME: WTF ?? - slide_category = 'presentation' - if vals.get('image_1920'): - image = Image.open(io.BytesIO(base64.b64decode(vals['image_1920']))) - width, height = image.size - if height > width: - return 'document' - return slide_category + Returns a dict containing metadata with the following keys (matching slide.slide fields): + - 'name' matching the external file title + - 'image_1920' binary data of the file thumbnail + OR 'image_url' containing an external link to the thumbnail when 'image_url_only' param is True + - 'completion_time' which is computed for 2 types of files: + - pdf files where we download the content and then use slide.slide#_get_completion_time_pdf() + - videos where we use the 'videoMediaMetadata' to extract the 'durationMillis' + + :param image_url_only: if True, will return 'image_url' instead of binary data + Typically used when displaying a slide preview to the end user. + :return a tuple (values, error) containing the values of the slide and a potential error + (e.g: 'File could not be found') """ - # Google drive doesn't use a simple API key to access the data, but requires an access - # token. However, this token is generated in module google_drive, which is not in the - # dependencies of website_slides. We still keep the 'key' parameter just in case, but that - # is probably useless. params = {} params['projection'] = 'BASIC' if 'google.drive.config' in self.env: - access_token = self.env['google.drive.config'].get_access_token() + access_token = False + try: + access_token = self.env['google.drive.config'].get_access_token() + except (RedirectWarning, UserError): + pass # ignore and use the 'key' fallback + if access_token: params['access_token'] = access_token + if not params.get('access_token'): params['key'] = self.env['website'].get_current_website().website_slide_google_app_key - fetch_res = self._fetch_data('https://www.googleapis.com/drive/v2/files/%s' % document_id, params, "json") - if fetch_res.get('error'): - return {'error': self._extract_google_error_message(fetch_res.get('error'))} + error_message = False + try: + response = requests.get( + 'https://www.googleapis.com/drive/v2/files/%s' % self.google_drive_id, + timeout=3, + params=params + ) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + error_message = e.response.content + if 'application/json' in e.response.headers.get('content-type'): + json_response = e.response.json() + if json_response.get('error', {}).get('code') == 404: + # in case we don't find the file on GDrive, we want to give some feedback to our user + return {}, _('Your file could not be found on Google Drive, please check the link and/or privacy settings') + except requests.exceptions.ConnectionError as e: + error_message = str(e) - google_values = fetch_res['values'] - if only_preview_fields: - return { - 'url_src': google_values['thumbnailLink'], - 'title': google_values['title'], - } + if not error_message: + response = response.json() + if response.get('error'): + error_message = response.get('error', {}).get('errors', [{}])[0].get('reason') - values = { - 'name': google_values['title'], - 'image_1920': self._fetch_data(google_values['thumbnailLink'].replace('=s220', ''), {}, 'image')['values'], - 'mime_type': google_values['mimeType'], - 'document_id': document_id, + if error_message: + _logger.warning('Could not fetch Google Drive metadata: %s', error_message) + return {}, error_message + + google_drive_values = response + slide_metadata = { + 'name': google_drive_values.get('title') } - if google_values['mimeType'].startswith('video/'): - values['slide_category'] = 'video' - elif google_values['mimeType'].startswith('image/'): - values['binary_content'] = values['image_1920'] - values['slide_category'] = 'infographic' - elif google_values['mimeType'].startswith('application/vnd.google-apps'): - values['slide_category'] = get_slide_category(values) - if 'exportLinks' in google_values: - values['binary_content'] = self._fetch_data(google_values['exportLinks']['application/pdf'], params, 'pdf')['values'] - elif google_values['mimeType'] == 'application/pdf': - # TODO: Google Drive PDF document doesn't provide plain text transcript - values['binary_content'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values'] - values['slide_category'] = get_slide_category(values) - return {'values': values} + if google_drive_values.get('thumbnailLink'): + # small trick, we remove '=s220' to get a higher definition + thumbnail_url = google_drive_values['thumbnailLink'].replace('=s220', '') + if image_url_only: + slide_metadata['image_url'] = thumbnail_url + else: + slide_metadata['image_1920'] = base64.b64encode( + requests.get(thumbnail_url, timeout=3).content + ) + + if self.slide_category == 'document': + sheet_mimetypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.google-apps.spreadsheet' + ] + + doc_mimetypes = [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.google-apps.document' + ] + + slides_mimetypes = [ + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.google-apps.presentation' + ] + + mime_type = google_drive_values.get('mimeType') + if mime_type == 'application/pdf': + slide_metadata['slide_type'] = 'pdf' + if google_drive_values.get('downloadUrl'): + # attempt to download PDF content to extract a completion_time based on the number of pages + try: + pdf_response = requests.get(google_drive_values.get('downloadUrl'), timeout=5) + completion_time = self._get_completion_time_pdf(pdf_response.content) + if completion_time: + slide_metadata['completion_time'] = completion_time + except Exception: + pass # fail silently as this is nice to have + elif mime_type in sheet_mimetypes: + slide_metadata['slide_type'] = 'sheet' + elif mime_type in doc_mimetypes: + slide_metadata['slide_type'] = 'doc' + elif mime_type in slides_mimetypes: + slide_metadata['slide_type'] = 'slides' + elif mime_type and mime_type.startswith('image/'): + # image and videos should be input using another "slide_category" but let's be nice and + # assign them a matching slide_type + slide_metadata['slide_type'] = 'image' + elif mime_type and mime_type.startswith('video/'): + slide_metadata['slide_type'] = 'google_drive_video' + + elif self.slide_category == 'video': + completion_time = float( + google_drive_values.get('videoMediaMetadata', {}).get('durationMillis', 0) + ) / (60 * 60 * 1000) # millis to hours conversion + if completion_time: + slide_metadata['completion_time'] = completion_time + + return slide_metadata, None def _default_website_meta(self): res = super(Slide, self)._default_website_meta() @@ -913,6 +1094,20 @@ class Slide(models.Model): # Data / Misc # --------------------------------------------------------- + def _get_completion_time_pdf(self, data_bytes): + """ For PDFs, we assume that it takes 5 minutes to read a page. + This method receives the data of the PDF as bytes. """ + + if data_bytes.startswith(b'%PDF-'): + try: + pdf = PyPDF2.PdfFileReader(io.BytesIO(data_bytes), overwriteWarnings=False) + return (5 * len(pdf.pages)) / 60 + except Exception: + pass # as this is a nice to have, fail silently + + return False + + def get_backend_menu_id(self): return self.env.ref('website_slides.website_slides_menu_root').id diff --git a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js index 5e4974c0437..9fd92d1e587 100644 --- a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js +++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js @@ -512,14 +512,22 @@ slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : ""; } else if (slideData.category === 'infographic') { slideData.embedUrl = _.str.sprintf('/web/image/slide.slide/%s/image_1024', slideData.id); - } else if (_.contains(['document', 'presentation'], slideData.category)) { + } else if (slideData.category === 'document') { slideData.embedUrl = $(slideData.embedCode).attr('src'); } // fill empty property to allow searching on it with _.filter(list, matcher) slideData.isQuiz = !!slideData.isQuiz; slideData.hasQuestion = !!slideData.hasQuestion; // technical settings for the Fullscreen to work - slideData._autoSetDone = _.contains(['infographic', 'presentation', 'document', 'webpage'], slideData.category) && !slideData.hasQuestion; + var autoSetDone = false; + if (!slideData.hasQuestion) { + if (_.contains(['infographic', 'document', 'webpage'], slideData.category)) { + autoSetDone = true; // images, documents (local + external) and web pages are marked as completed when opened + } else if (slideData.category === 'video' && slideData.videoSourceType === 'google_drive') { + autoSetDone = true; // google drive videos do not benefit from the YouTube integration and are marked as completed when opened + } + } + slideData._autoSetDone = autoSetDone; }); return slidesDataList; }, @@ -563,7 +571,7 @@ } // render slide content - if (_.contains(['document', 'presentation', 'infographic'], slide.category)) { + if (_.contains(['document', 'infographic'], slide.category)) { $content.html(QWeb.render('website.slides.fullscreen.content', {widget: this})); } else if (slide.category === 'video') { this.videoPlayer = new VideoPlayer(this, slide); @@ -627,7 +635,7 @@ return self._renderSlide(); }).then(function() { if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call - if (['document', 'presentation'].includes(slide.category)) { + if (slide.category === 'document') { // only set the slide as completed after iFrame is loaded to avoid concurrent execution with 'embedUrl' controller self.el.querySelector('iframe.o_wslides_iframe_viewer').addEventListener('load', () => self._setCompleted(slide.id)); } else { diff --git a/addons/website_slides/static/src/js/slides_upload.js b/addons/website_slides/static/src/js/slides_upload.js index 14c605ee323..d36abe64b05 100644 --- a/addons/website_slides/static/src/js/slides_upload.js +++ b/addons/website_slides/static/src/js/slides_upload.js @@ -12,7 +12,10 @@ var SlideUploadDialog = Dialog.extend({ 'click .o_wslides_js_upload_install_button': '_onClickInstallModule', 'click .o_wslides_select_category': '_onClickSlideCategoryIcon', 'change input#upload': '_onChangeSlideUpload', - 'change input#url': '_onChangeSlideUrl', + 'change input#video_url': '_onChangeVideoUrl', + 'change input#image_google_url': '_onChangeImageUrl', + 'change input#document_google_url': '_onChangeDocumentUrl', + 'change input[name="source_type"]': '_onChangeSourceType', }), /** @@ -24,7 +27,7 @@ var SlideUploadDialog = Dialog.extend({ */ init: function (parent, options) { options = _.defaults(options || {}, { - title: _t("Upload a document"), + title: _t("Upload new content"), size: 'medium', }); this._super(parent, options); @@ -99,11 +102,12 @@ var SlideUploadDialog = Dialog.extend({ }); })); }, - _fetchUrlPreview: function (url) { + _fetchUrlPreview: function (url, slideCategory) { return this._rpc({ route: '/slides/prepare_preview/', params: { 'url': url, + 'slide_category': slideCategory, 'channel_id': this.channelID }, }); @@ -126,46 +130,47 @@ var SlideUploadDialog = Dialog.extend({ * @private */ _formValidateGetValues: async function (forcePublished) { - var canvas = this.$('#data_canvas')[0]; - var values = _.extend({ - 'channel_id': this.channelID, - 'name': this._formGetFieldValue('name'), - 'url': this._formGetFieldValue('url'), - 'description': this._formGetFieldValue('description'), - 'duration': this._formGetFieldValue('duration'), - 'is_published': forcePublished, - }, this._getSelect2DropdownValues()); // add tags and category - + var slideCategory = 'document'; // default slide_category (for webpage for instance) if (_.contains(this.slide_category_data), this.get('state')) { - values['slide_category'] = this.get('state'); + slideCategory = this.get('state'); } + var sourceType = 'local_file'; + if (slideCategory === 'video') { + sourceType = 'external'; // force external for videos + } else { + sourceType = this.$('input[name="source_type"]:checked').data('value'); + } + var values = _.extend({ + 'channel_id': this.channelID, + 'description': this._formGetFieldValue('description'), + 'document_google_url': this._formGetFieldValue('document_google_url'), + 'duration': this._formGetFieldValue('duration'), + 'image_google_url': this._formGetFieldValue('image_google_url'), + 'is_published': forcePublished, + 'name': this._formGetFieldValue('name'), + 'slide_category': slideCategory, + 'source_type': sourceType, + 'video_url': this._formGetFieldValue('video_url'), + }, this._getSelect2DropdownValues()); // add tags and category + + var canvas = this.$('#data_canvas')[0]; if (this.file.type === 'application/pdf') { _.extend(values, { 'image_1920': canvas.toDataURL().split(',')[1], - 'slide_category': canvas.height > canvas.width ? 'document' : 'presentation', - 'mime_type': this.file.type, + 'slide_category': 'document', 'binary_content': this.file.data }); } else if (values['slide_category'] === 'webpage') { _.extend(values, { - 'mime_type': 'text/html', - 'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data, + 'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPng() : this.file.data, }); } else if (/^image\/.*/.test(this.file.type)) { - const fileData = this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data; - if (values['slide_category'] === 'presentation') { - _.extend(values, { - 'slide_category': 'infographic', - 'mime_type': this.file.type === 'image/svg+xml' ? 'image/png' : this.file.type, - 'binary_content': fileData, - }); - } else { - _.extend(values, { - 'image_1920': fileData, - }); - } + _.extend(values, { + 'slide_category': 'infographic', + 'binary_content': this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data, + }); } return values; }, @@ -327,10 +332,15 @@ var SlideUploadDialog = Dialog.extend({ */ _setup: function () { this.slide_category_data = { - presentation: { + document: { icon: 'fa-file-pdf-o', - label: _t('Presentation'), - template: 'website.slide.upload.modal.presentation', + label: _t('Document'), + template: 'website.slide.upload.modal.document', + }, + infographic: { + icon: 'fa-file-image-o', + label: _t('Infographic'), + template: 'website.slide.upload.modal.infographic', }, webpage: { icon: 'fa-file-text', @@ -378,6 +388,68 @@ var SlideUploadDialog = Dialog.extend({ return png.split(',')[1]; }, + /** + * When the URL changes for slides of categories infographic, document and video, we attempt to fetch + * some metadata on YouTube / Google Drive (such as a name, a title, a duration, ...). + * + * @param {string} url + * @param {string} slideCategory + */ + _updateSlideMetadata: function (url, slideCategory) { + var self = this; + this._alertRemove(); + this.isValidUrl = false; + this.set('can_submit_form', false); + this._setModalLoading(true); + this._fetchUrlPreview(url, slideCategory).then(function (data) { + self.set('can_submit_form', true); + if (data.error) { + self._alertDisplay(data.error); + self._hidePreviewColumn(); + } else { + self.isValidUrl = true; + + if (data.name) { + self._formSetFieldValue('name', data.name); + } + if (data.description) { + self._formSetFieldValue('description', data.description); + } + if (data.completion_time) { + // hours to minutes conversion + self._formSetFieldValue('duration', Math.round(data.completion_time * 60)); + } + if (data.image_url) { + self.$('#slide-image').attr('src', data.image_url); + } + + if (!data.name && !data.description && !data.image_url) { + self._hidePreviewColumn(); + } else { + self._showPreviewColumn(); + } + } + + self._setModalLoading(false); + }); + }, + + /** + * Typically used when loading slide metadata. + * Since the request result will change form values, it's better to wait for the return of the + * request to avoid having the user type text that will be overridden (such as the slide name + * for example). + * + * @param {boolean} loading true to mask the modal with a loading screen, false to remove it + */ + _setModalLoading: function (loading) { + if (loading) { + this.$el.closest('.modal-content').append(QWeb.render('website.slide.upload.modal.loading')); + } else { + this.$el.closest('.modal-content').find('.o_wslides_slide_upload_loading').remove(); + } + }, + //-------------------------------------------------------------------------- // Handler //-------------------------------------------------------------------------- @@ -404,7 +476,7 @@ var SlideUploadDialog = Dialog.extend({ if (currentType === '_import') { this.set_title(_t("New Certification")); } else { - this.set_title(_t("Upload a document")); + this.set_title(_t("Upload new content")); } }, _onChangeCanSubmitForm: function (ev) { @@ -414,6 +486,28 @@ var SlideUploadDialog = Dialog.extend({ this.$('.o_w_slide_upload').button('loading'); } }, + /** + * When the user selects 'local_file' or 'external' as source type, we display the 'upload' + * field or the 'document_google_url' / 'image_google_url' fields respectively. + * We also toggle the 'required' attribute the same way. + * + * @param {Event} ev the onchange event + */ + _onChangeSourceType: function (ev) { + if (this.$('#source_type_local_file').is(':checked')) { + this.$('.o_wslides_js_slide_upload_local_file').removeClass('d-none'); + this.$('.o_wslides_js_slide_upload_external').addClass('d-none'); + + this.$('#upload').attr('required', 'required'); + this.$('#document_google_url, #image_google_url').removeAttr('required'); + } else if (this.$('#source_type_external').is(':checked')) { + this.$('.o_wslides_js_slide_upload_external').removeClass('d-none'); + this.$('.o_wslides_js_slide_upload_local_file').addClass('d-none'); + + this.$('#document_google_url, #image_google_url').attr('required', 'required'); + this.$('#upload').removeAttr('required'); + } + }, _onChangeSlideUpload: function (ev) { var self = this; this._alertRemove(); @@ -519,31 +613,15 @@ var SlideUploadDialog = Dialog.extend({ } } }, - _onChangeSlideUrl: function (ev) { - var self = this; - var url = $(ev.target).val(); - this._alertRemove(); - this.isValidUrl = false; - this.set('can_submit_form', false); - this._fetchUrlPreview(url).then(function (data) { - self.set('can_submit_form', true); - if (data.error) { - self._alertDisplay(data.error); - } else { - if (data.completion_time) { - // hours to minutes conversion - self._formSetFieldValue('duration', Math.round(data.completion_time * 60)); - } - self.$('#slide-image').attr('src', data.url_src); - self._formSetFieldValue('name', data.title); - self._formSetFieldValue('description', data.description); - - self.isValidUrl = true; - self._showPreviewColumn(); - } - }); + _onChangeDocumentUrl: function (ev) { + this._updateSlideMetadata($(ev.target).val(), 'document'); + }, + _onChangeImageUrl: function (ev) { + this._updateSlideMetadata($(ev.target).val(), 'infographic'); + }, + _onChangeVideoUrl: function (ev) { + this._updateSlideMetadata($(ev.target).val(), 'video'); }, - _onClickInstallModule: function (ev) { var $btn = $(ev.currentTarget); var moduleId = $btn.data('moduleId'); diff --git a/addons/website_slides/static/src/js/tours/slides_tour.js b/addons/website_slides/static/src/js/tours/slides_tour.js index 6d5396defcc..dab72c581ad 100644 --- a/addons/website_slides/static/src/js/tours/slides_tour.js +++ b/addons/website_slides/static/src/js/tours/slides_tour.js @@ -45,8 +45,8 @@ tour.register('slides_tour', { content: Markup(_t("Your first section is created, now it's time to add lessons to your course. Click on Add Content to upload a document, create a web page or link a video.")), position: 'bottom', }, { - trigger: 'a[data-slide-category="presentation"]', - content: Markup(_t("First, let's add a Presentation. It can be a .pdf or an image.")), + trigger: 'a[data-slide-category="document"]', + content: Markup(_t("First, let's add a Document. It has to be a .pdf file.")), position: 'bottom', }, { trigger: 'input#upload', diff --git a/addons/website_slides/static/src/scss/website_slides.scss b/addons/website_slides/static/src/scss/website_slides.scss index 6ec12ef722e..fb74c1d2377 100644 --- a/addons/website_slides/static/src/scss/website_slides.scss +++ b/addons/website_slides/static/src/scss/website_slides.scss @@ -515,6 +515,25 @@ $truncate-limits: 2, 3, 10; // Modals // ************************************************** +.o_w_slide_upload_modal_container { + .o_slide_preview { + display: flex; // not using d-flex because it messes with d-none + } + + .form-check { + line-height: 1.5rem; // necessary to align label correctly with radio button + + } + + .was-validated .form-check .form-check-label { + @include o-w-preserve-base; // avoid ugly green when form is marked as 'was-validated' + } +} + +.o_wslides_slide_upload_loading { + background-color: rgba(0, 0, 0, .3); +} + .o_wslides_quiz_modal { @include media-breakpoint-up (sm) { .modal-body { diff --git a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml index f22d109bfb2..d34749cb85f 100644 --- a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml +++ b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml @@ -1,7 +1,7 @@ - +
') % (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': @@ -425,6 +444,9 @@ class Slide(models.Model): 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 @@ -440,6 +462,23 @@ class Slide(models.Model): else: slide.youtube_id = False + @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.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. """ @@ -868,6 +907,8 @@ class Slide(models.Model): 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) @@ -1082,6 +1123,73 @@ class Slide(models.Model): 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() res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name 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 9fd92d1e587..0882af227b6 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: @@ -501,7 +594,7 @@ // compute hasNext slide slideData.hasNext = index < slidesDataList.length-1; // compute embed url - if (slideData.category === '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,6 +603,8 @@ params.autoplay = 1; } slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : ""; + } 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 (slideData.category === 'document') { @@ -573,9 +668,14 @@ // render slide content if (_.contains(['document', 'infographic'], slide.category)) { $content.html(QWeb.render('website.slides.fullscreen.content', {widget: this})); - } else if (slide.category === 'video') { - this.videoPlayer = new VideoPlayer(this, slide); + } else if (slide.category === 'video' && slide.videoSourceType === 'youtube') { + this.videoPlayer = new VideoPlayerYouTube(this, slide); return this.videoPlayer.appendTo($content); + } 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 === 'webpage'){ var $wpContainer = $('
').addClass('o_wslide_fs_webpage_content bg-white block w-100 overflow-auto'); $(slide.htmlContent).appendTo($wpContainer); 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 d34749cb85f..a4415789b97 100644 --- a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml +++ b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml @@ -13,10 +13,22 @@ - + +
+ +
+
+ +
+ +
+ +
+
+ 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 7a37497b05a..775267e464d 100644 --- a/addons/website_slides/static/src/xml/website_slides_upload.xml +++ b/addons/website_slides/static/src/xml/website_slides_upload.xml @@ -239,7 +239,7 @@
- +
@@ -253,6 +253,20 @@
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.
diff --git a/addons/website_slides/tests/test_slide_utils.py b/addons/website_slides/tests/test_slide_utils.py index df47492403a..e46130a61e1 100644 --- a/addons/website_slides/tests/test_slide_utils.py +++ b/addons/website_slides/tests/test_slide_utils.py @@ -168,7 +168,7 @@ class TestSequencing(slides_common.SlidesCase): class TestFromURL(slides_common.SlidesCase): - def test_video_urls(self): + def test_video_youtube(self): youtube_urls = { 'W0JQcpGLSFw': [ 'https://youtu.be/W0JQcpGLSFw', @@ -203,12 +203,60 @@ class TestFromURL(slides_common.SlidesCase): self.assertEqual('youtube', slide.video_source_type) self.assertEqual(youtube_id, slide.youtube_id) - # test URL from Google Drive when user hits the "share" button (main use case) - slide = Slide.create({ - 'name': 'dummy', - 'channel_id': self.channel.id, - 'url': 'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing', - 'slide_category': 'video' - }) - self.assertEqual('google_drive', slide.video_source_type) - self.assertEqual('1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR', slide.google_drive_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/views/website_slides_templates_course.xml b/addons/website_slides/views/website_slides_templates_course.xml index 330cf695060..ca952f6391b 100644 --- a/addons/website_slides/views/website_slides_templates_course.xml +++ b/addons/website_slides/views/website_slides_templates_course.xml @@ -812,6 +812,7 @@ + From ffa504887d67526268d5dd3287566bf4444b1d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Wed, 1 Sep 2021 09:21:31 +0000 Subject: [PATCH 4/5] [IMP] website_slides: slightly improve various e-learning views PURPOSE This commit adds some small UI improvements to the e-learning views. All those small changes are meant to improve the general user experience when adding content to a course. DETAILED SPECS - Change some placeholders / labels - Inform the end-user that he can edit his 'webpage' slide using the editor - Inform the end-user that he can add questions to his 'quiz' slide using the 'Quiz' tab in the backend form view - Do not update from slide metadata if field value is already set - Do not allow previews for certifications - "Allow Download" of slide content is now False by default As it was nice to have for "Documentation" courses but does not really make sense for "Training" ones. - Having a duplicate video id is now informative on the frontend and not blocking as before (it was allowed in the backend form anyway). - Show the video title in the frontend preview Task-2510174 Part-of: odoo/odoo#71477 --- addons/website_slides/controllers/main.py | 10 ++++-- addons/website_slides/models/slide_slide.py | 19 +++++++---- .../static/src/js/slides_category_add.js | 2 +- .../static/src/js/slides_course_tag_add.js | 2 +- .../static/src/js/slides_upload.js | 33 ++++++++++++++----- .../static/src/xml/website_slides_upload.xml | 28 +++++++++------- .../views/slide_slide_views.xml | 9 ++++- .../views/website_slides_templates_course.xml | 4 +-- .../views/website_slides_templates_lesson.xml | 9 +++-- ...ite_slides_templates_lesson_fullscreen.xml | 4 +-- .../models/slide_slide.py | 8 +++++ .../views/slide_slide_views.xml | 3 ++ .../views/website_slides_templates_course.xml | 2 +- 13 files changed, 92 insertions(+), 41 deletions(-) diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index 86a2cb0936c..8bc6a1ef68c 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -1067,6 +1067,7 @@ class WebsiteSlides(WebsiteProfile): Slide = request.env['slide.slide'] + additional_values = {} if slide_category == 'video': identical_video = request.env['slide.slide'] existing_videos = Slide.search([ @@ -1083,7 +1084,7 @@ class WebsiteSlides(WebsiteProfile): if not slide.video_source_type: slide.unlink() - return {'error': _('Please enter valid YouTube, Vimeo or Google Drive Link')} + 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( @@ -1096,7 +1097,7 @@ class WebsiteSlides(WebsiteProfile): lambda existing_video: slide.vimeo_id == existing_video.vimeo_id) if identical_video: identical_video_name = identical_video[0].name - return {'error': _('This video already exists in this channel on the following content: %s', identical_video_name)} + 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), @@ -1113,6 +1114,9 @@ class WebsiteSlides(WebsiteProfile): 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) @@ -1171,7 +1175,7 @@ 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_category == "webpage": + if channel.channel_type == "training" and slide.slide_category not in ["webpage", "quiz"]: redirect_url = "/slides/%s" % (slug(channel)) if slide.slide_category == 'webpage': redirect_url += "?enable_editor=1" diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index 0b2eed48332..f0759b91ca3 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -124,7 +124,7 @@ class Slide(models.Model): quiz_fourth_attempt_reward = fields.Integer("Reward: every attempt after the third try", default=2) # content slide_category = fields.Selection([ - ('infographic', 'Infographic'), + ('infographic', 'Image'), ('webpage', 'Web Page'), ('document', 'Document'), ('video', 'Video'), @@ -132,14 +132,14 @@ class Slide(models.Model): string='Category', required=True, default='document') source_type = fields.Selection([ - ('local_file', 'Local File'), - ('external', 'External (Google Drive)')], + ('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.") + 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 @@ -168,7 +168,7 @@ class Slide(models.Model): document_binary_content = fields.Binary('PDF Content', related='binary_content', readonly=False, help="Used to filter file input to PDF only") # content - videos - video_url = fields.Char('Video URL', related='url', readonly=False, + 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'), @@ -200,7 +200,7 @@ class Slide(models.Model): # Statistics in case the slide is a category 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_infographic = fields.Integer("Number of Images", compute='_compute_slides_statistics', store=True) nbr_webpage = fields.Integer("Number of Webpages", 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) @@ -497,13 +497,18 @@ class Slide(models.Model): def _on_change_url(self): """ 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. """ self.ensure_one() if self.url or self.document_google_url or self.image_google_url or self.video_url: slide_metadata, _error = self._fetch_external_metadata() if slide_metadata: - self.update(slide_metadata) + 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): 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_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 d36abe64b05..afb1f74c686 100644 --- a/addons/website_slides/static/src/js/slides_upload.js +++ b/addons/website_slides/static/src/js/slides_upload.js @@ -27,7 +27,7 @@ var SlideUploadDialog = Dialog.extend({ */ init: function (parent, options) { options = _.defaults(options || {}, { - title: _t("Upload new content"), + title: _t("Add Content"), size: 'medium', }); this._super(parent, options); @@ -63,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')); @@ -113,7 +113,10 @@ var SlideUploadDialog = Dialog.extend({ }); }, _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(); @@ -192,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) { @@ -202,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; }, @@ -339,7 +342,7 @@ var SlideUploadDialog = Dialog.extend({ }, infographic: { icon: 'fa-file-image-o', - label: _t('Infographic'), + label: _t('Image'), template: 'website.slide.upload.modal.infographic', }, webpage: { @@ -348,7 +351,7 @@ var SlideUploadDialog = Dialog.extend({ template: 'website.slide.upload.modal.webpage', }, video: { - icon: 'fa-video-camera', + icon: 'fa-file-video-o', label: _t('Video'), template: 'website.slide.upload.modal.video', }, @@ -407,11 +410,23 @@ var SlideUploadDialog = Dialog.extend({ 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); } @@ -476,7 +491,7 @@ var SlideUploadDialog = Dialog.extend({ if (currentType === '_import') { this.set_title(_t("New Certification")); } else { - this.set_title(_t("Upload new content")); + this.set_title(_t("Add Content")); } }, _onChangeCanSubmitForm: function (ev) { 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 775267e464d..3a2af6c904e 100644 --- a/addons/website_slides/static/src/xml/website_slides_upload.xml +++ b/addons/website_slides/static/src/xml/website_slides_upload.xml @@ -14,7 +14,7 @@ -->
-
+
- +
- +
Minutes
@@ -94,13 +94,13 @@
@@ -110,7 +110,8 @@
- +
@@ -120,7 +121,7 @@
How do I add new content?
- You can either choose a local file from your computer or insert a valid Google Drive link.
+ You can either upload a file from your computer or insert a Google Drive link.
What types of documents do we support?
@@ -169,13 +170,13 @@
@@ -185,7 +186,8 @@
- +
@@ -195,7 +197,7 @@
How do I add new content?
- You can either choose a local file from your computer or insert a valid Google Drive link.
+ 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 @@ -239,7 +241,8 @@
- +
@@ -276,6 +279,7 @@
Content Preview +
diff --git a/addons/website_slides/views/slide_slide_views.xml b/addons/website_slides/views/slide_slide_views.xml index cf776939d11..32688b4fabe 100644 --- a/addons/website_slides/views/slide_slide_views.xml +++ b/addons/website_slides/views/slide_slide_views.xml @@ -118,12 +118,19 @@ - + You can add questions to this quiz in the 'Quiz' tab. +
+
- + Preview @@ -685,7 +685,7 @@ t-att-data-channel-id="channel.id" t-att-data-can-upload="channel.can_upload" t-att-data-can-publish="channel.can_publish"> - Upload new content + Add Content
-
-
+
+
+ Click on the "Edit" button on the top-right of the screen to edit your slide content. +
+
+
+
diff --git a/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml b/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml index f72b6ccc590..866f4698386 100644 --- a/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml +++ b/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml @@ -123,14 +123,14 @@
- +
- +
diff --git a/addons/website_slides_survey/models/slide_slide.py b/addons/website_slides_survey/models/slide_slide.py index 07ea5531dd5..882c176158b 100644 --- a/addons/website_slides_survey/models/slide_slide.py +++ b/addons/website_slides_survey/models/slide_slide.py @@ -38,12 +38,20 @@ class Slide(models.Model): ], 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_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: diff --git a/addons/website_slides_survey/views/slide_slide_views.xml b/addons/website_slides_survey/views/slide_slide_views.xml index 01f8d2f9142..ec9274771a4 100644 --- a/addons/website_slides_survey/views/slide_slide_views.xml +++ b/addons/website_slides_survey/views/slide_slide_views.xml @@ -10,6 +10,9 @@ attrs="{'invisible': [('slide_category', '!=', 'certification')], 'required': [('slide_category', '=', 'certification')]}" domain="[('certification', '=', True)]" context="{'default_certification': True, 'default_scoring_type': 'scoring_without_answers'}"/> + + {'invisible': [('slide_category', '=', 'certification')]} + 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 61521ccadae..4f00d2940f3 100644 --- a/addons/website_slides_survey/views/website_slides_templates_course.xml +++ b/addons/website_slides_survey/views/website_slides_templates_course.xml @@ -31,7 +31,7 @@ channel.can_upload and not slide.question_ids and slide.slide_category != 'certification' - + #{'d-none' if slide.slide_type == 'certification' else ''} From 22d1743e26afaefbca198905fca133d8e5b7b413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Tue, 26 Oct 2021 07:41:28 +0000 Subject: [PATCH 5/5] [REF] website_slides: rename 'webpage' to 'article' This commit simply renames the 'webpage' slide_category into 'article'. Indeed, 'webpage' was confusing because it was leading you to believe that you would have to link external content from another website. While this slide_category instead lets you build an 'article' with content by using our website builder. The changes here are just renaming the slide_category and the labels, there should not be any functional change. Task-2510174 Part-of: odoo/odoo#71477 --- addons/website_slides/__manifest__.py | 2 +- addons/website_slides/controllers/main.py | 4 ++-- addons/website_slides/data/slide_slide_demo.xml | 6 +++--- addons/website_slides/models/slide_channel.py | 2 +- addons/website_slides/models/slide_slide.py | 14 +++++++------- .../src/js/slides_course_fullscreen_player.js | 14 +++++++------- .../website_slides/static/src/js/slides_upload.js | 10 +++++----- .../static/src/js/tours/slides_tour.js | 2 +- .../static/src/tests/tours/slides_tour_tools.js | 12 ++++++------ .../static/src/xml/website_slides_upload.xml | 4 ++-- addons/website_slides/views/slide_slide_views.xml | 4 ++-- .../views/website_slides_templates_course.xml | 4 ++-- .../views/website_slides_templates_lesson.xml | 4 ++-- 13 files changed, 41 insertions(+), 41 deletions(-) diff --git a/addons/website_slides/__manifest__.py b/addons/website_slides/__manifest__.py index 0562c75d486..7081492b55a 100644 --- a/addons/website_slides/__manifest__.py +++ b/addons/website_slides/__manifest__.py @@ -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 8bc6a1ef68c..b3bb297e078 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -1175,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 slide.slide_category not in ["webpage", "quiz"]: + if channel.channel_type == "training" and slide.slide_category not in ["article", "quiz"]: redirect_url = "/slides/%s" % (slug(channel)) - if slide.slide_category == 'webpage': + if slide.slide_category == 'article': redirect_url += "?enable_editor=1" return { 'url': redirect_url, diff --git a/addons/website_slides/data/slide_slide_demo.xml b/addons/website_slides/data/slide_slide_demo.xml index 029ae026aa4..1eea9658457 100644 --- a/addons/website_slides/data/slide_slide_demo.xml +++ b/addons/website_slides/data/slide_slide_demo.xml @@ -225,7 +225,7 @@ A little chat with Harry Potted 6 - webpage + article
@@ -462,7 +462,7 @@ A Mighty Forest from Ages 2 - webpage + article
@@ -605,7 +605,7 @@ Foreword 1 - webpage + article
diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index b4aa596645c..158a3fa2b36 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -186,7 +186,7 @@ class Channel(models.Model): 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) diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index f0759b91ca3..33eb7933f9a 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -125,7 +125,7 @@ class Slide(models.Model): # content slide_category = fields.Selection([ ('infographic', 'Image'), - ('webpage', 'Web Page'), + ('article', 'Article'), ('document', 'Document'), ('video', 'Video'), ('quiz', "Quiz")], @@ -143,7 +143,7 @@ class Slide(models.Model): # google google_drive_id = fields.Char('Google Drive ID of the external URL', compute='_compute_google_drive_id') # content - webpage - html_content = fields.Html("HTML Content", help="Custom HTML content for slides of category 'Web Page'.", translate=True, sanitize_attributes=False, sanitize_form=False) + 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") @@ -152,7 +152,7 @@ class Slide(models.Model): # content - documents slide_type = fields.Selection([ ('image', 'Image'), - ('webpage', 'Web Page'), + ('article', 'Article'), ('quiz', 'Quiz'), ('pdf', 'PDF'), ('sheet', 'Sheet (Excel, Google Sheet, ...)'), @@ -201,7 +201,7 @@ class Slide(models.Model): 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 Images", compute='_compute_slides_statistics', store=True) - nbr_webpage = fields.Integer("Number of Webpages", 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) @@ -366,8 +366,8 @@ class Slide(models.Model): slide.slide_type = False elif slide.slide_category == 'infographic': slide.slide_type = 'image' - elif slide.slide_category == 'webpage': - slide.slide_type = 'webpage' + elif slide.slide_category == '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': @@ -1252,7 +1252,7 @@ class Slide(models.Model): def _search_render_results(self, fetch_fields, mapping, icon, limit): 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', 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 0882af227b6..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 @@ -548,7 +548,7 @@ // Private //-------------------------------------------------------------------------- /** - * Fetches content with an rpc call for slides of category "webpage" + * Fetches content with an rpc call for slides of category "article" * * @private */ @@ -574,7 +574,7 @@ */ _fetchSlideContent: function (){ var slide = this.get('slide'); - if (slide.category === 'webpage' && !slide.isQuiz) { + if (slide.category === 'article' && !slide.isQuiz) { return this._fetchHtmlContent(); } return Promise.resolve(); @@ -616,8 +616,8 @@ // technical settings for the Fullscreen to work var autoSetDone = false; if (!slideData.hasQuestion) { - if (_.contains(['infographic', 'document', 'webpage'], slideData.category)) { - autoSetDone = true; // images, documents (local + external) and web pages are marked as completed when opened + 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 } @@ -646,7 +646,7 @@ }, /** * Render the current slide content using specific mecanism according to slide category: - * - simply append content (for webpage) + * - simply append content (for article) * - template rendering (for image, document, ....) * - using a sub widget (quiz and video) * @@ -676,8 +676,8 @@ 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 === 'webpage'){ - var $wpContainer = $('
').addClass('o_wslide_fs_webpage_content bg-white block w-100 overflow-auto'); + } 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', { diff --git a/addons/website_slides/static/src/js/slides_upload.js b/addons/website_slides/static/src/js/slides_upload.js index afb1f74c686..f7f901c82cc 100644 --- a/addons/website_slides/static/src/js/slides_upload.js +++ b/addons/website_slides/static/src/js/slides_upload.js @@ -134,7 +134,7 @@ var SlideUploadDialog = Dialog.extend({ */ _formValidateGetValues: async function (forcePublished) { var slideCategory = 'document'; - // default slide_category (for webpage for instance) + // default slide_category (for article for instance) if (_.contains(this.slide_category_data), this.get('state')) { slideCategory = this.get('state'); } @@ -165,7 +165,7 @@ var SlideUploadDialog = Dialog.extend({ 'slide_category': 'document', 'binary_content': this.file.data }); - } else if (values['slide_category'] === 'webpage') { + } else if (values['slide_category'] === 'article') { _.extend(values, { 'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPng() : this.file.data, }); @@ -345,10 +345,10 @@ var SlideUploadDialog = Dialog.extend({ label: _t('Image'), template: 'website.slide.upload.modal.infographic', }, - webpage: { + 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-file-video-o', 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 dab72c581ad..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,7 +42,7 @@ 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-category="document"]', 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 62f7f4b334d..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 @@ -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-category=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_upload.xml b/addons/website_slides/static/src/xml/website_slides_upload.xml index 3a2af6c904e..f5874a0fa5b 100644 --- a/addons/website_slides/static/src/xml/website_slides_upload.xml +++ b/addons/website_slides/static/src/xml/website_slides_upload.xml @@ -213,7 +213,7 @@
- +
@@ -224,7 +224,7 @@
-
How to create a Lesson as a Web Page?
+
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.
diff --git a/addons/website_slides/views/slide_slide_views.xml b/addons/website_slides/views/slide_slide_views.xml index 32688b4fabe..3f8affcb44d 100644 --- a/addons/website_slides/views/slide_slide_views.xml +++ b/addons/website_slides/views/slide_slide_views.xml @@ -271,8 +271,8 @@ - - + + diff --git a/addons/website_slides/views/website_slides_templates_course.xml b/addons/website_slides/views/website_slides_templates_course.xml index ebc316a4483..e753b91f439 100644 --- a/addons/website_slides/views/website_slides_templates_course.xml +++ b/addons/website_slides/views/website_slides_templates_course.xml @@ -526,7 +526,7 @@ - + @@ -813,7 +813,7 @@ - + diff --git a/addons/website_slides/views/website_slides_templates_lesson.xml b/addons/website_slides/views/website_slides_templates_lesson.xml index c3583310be6..5cf06065df4 100644 --- a/addons/website_slides/views/website_slides_templates_lesson.xml +++ b/addons/website_slides/views/website_slides_templates_lesson.xml @@ -229,7 +229,7 @@ t-att-href="'/slides/slide/%s' % (slug(previous_slide)) if previous_slide else '#'"> Prev - + @@ -261,7 +261,7 @@
-
+
Click on the "Edit" button on the top-right of the screen to edit your slide content.