[MERGE][IMP] website_slides: refactor content management + vimeo support

This merge commit has five sub-sections:

1. Rename 'slide_type' and 'datas' fields

To 'slide_category' and 'binary_content', in order to ease the following
changes.

See underlying commits for detailed information about those points.

2. Completely refactor the way we handle external content in the course slides.

This includes both technical (new fields, ...) and functional (forms) changes.
With these changes, we aim to make it much clearer for the end user on how to
introduce new content to its course, both for local files and external links.

3. Introduce support for Vimeo

Vimeo is a platform for professionals that manage video content, similarly to
YouTube.
The changes include allowing to introduce Vimeo links in your slides, correctly
embed the video, automatically set it as completed in the last 30 seconds,
fetch video metadata to autocomplete fields when URL is input, ...

4. Slightly improve e-learning views

See underlying commits for detailed information about those points.

5. Rename 'webpage' to 'article'

Indeed, 'webpage' was confusing because it was leading you to believe that you
would have to link external content from another website.

See underlying commits for detailed information about those points.

LINKS

Task-2510174
UPG PR odoo/upgrade#2498

closes odoo/odoo#71477

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2021-11-30 16:21:50 +01:00
committed by GitHub
44 changed files with 1491 additions and 651 deletions
@@ -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,
+2 -2
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'eLearning',
'version': '2.4',
'version': '2.5',
'sequence': 125,
'summary': 'Manage and publish an eLearning platform',
'website': 'https://www.odoo.com/app/elearning',
@@ -14,7 +14,7 @@ Featuring
* Integrated course and lesson management
* Fullscreen navigation
* Support Youtube videos, Google documents, PDF, images, web pages
* Support Youtube videos, Google documents, PDF, images, articles
* Test knowledge with quizzes
* Filter and Tag
* Statistics
+98 -40
View File
@@ -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/<string:slug_tags>'], 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/<model("slide.channel"):channel>/category/<model("slide.slide"):category>',
'/slides/<model("slide.channel"):channel>/category/<model("slide.slide"):category>/page/<int:page>',
], type='http', auth="public", website=True, sitemap=sitemap_slide)
def channel(self, channel, category=None, tag=None, page=1, slide_type=None, uncategorized=False, sorting=None, search=None, **kw):
def channel(self, channel, category=None, tag=None, page=1, slide_category=None, uncategorized=False, sorting=None, search=None, **kw):
"""
Will return all necessary data to display the requested slide_channel along with a possible category.
"""
# retro-compatibility for older links, 'slide_category' field was previously named 'slide_type'
# can be safely removed after 15.3 (I swear though, don't be afraid, remove it!)
slide_category = slide_category or kw.get('slide_type')
domain = self._get_channel_slides_base_domain(channel)
pager_url = "/slides/%s" % (channel.id)
pager_args = {}
slide_types = dict(request.env['slide.slide']._fields['slide_type']._description_selection(request.env))
slide_categories = dict(request.env['slide.slide']._fields['slide_category']._description_selection(request.env))
if search:
domain += [
@@ -443,9 +452,9 @@ class WebsiteSlides(WebsiteProfile):
if uncategorized:
domain += [('category_id', '=', False)]
pager_args['uncategorized'] = 1
elif slide_type:
domain += [('slide_type', '=', slide_type)]
pager_url += "?slide_type=%s" % slide_type
elif slide_category:
domain += [('slide_category', '=', slide_category)]
pager_url += "?slide_category=%s" % slide_category
# sorting criterion
if channel.channel_type == 'documentation':
@@ -467,8 +476,8 @@ class WebsiteSlides(WebsiteProfile):
query_string = "?search_category=%s" % category.id
elif tag:
query_string = "?search_tag=%s" % tag.id
elif slide_type:
query_string = "?search_slide_type=%s" % slide_type
elif slide_category:
query_string = "?search_slide_category=%s" % slide_category
elif uncategorized:
query_string = "?search_uncategorized=1"
@@ -479,10 +488,10 @@ class WebsiteSlides(WebsiteProfile):
# search
'search_category': category,
'search_tag': tag,
'search_slide_type': slide_type,
'search_slide_category': slide_category,
'search_uncategorized': uncategorized,
'query_string': query_string,
'slide_types': slide_types,
'slide_categories': slide_categories,
'sorting': actual_sorting,
'search': search,
# display data
@@ -704,8 +713,8 @@ class WebsiteSlides(WebsiteProfile):
values.update({
'search_category': slide.category_id if kwargs.get('search_category') else None,
'search_tag': request.env['slide.tag'].browse(int(kwargs.get('search_tag'))) if kwargs.get('search_tag') else None,
'slide_types': dict(request.env['slide.slide']._fields['slide_type']._description_selection(request.env)) if kwargs.get('search_slide_type') else None,
'search_slide_type': kwargs.get('search_slide_type'),
'slide_categories': dict(request.env['slide.slide']._fields['slide_category']._description_selection(request.env)) if kwargs.get('search_slide_category') else None,
'search_slide_category': kwargs.get('search_slide_category'),
'search_uncategorized': kwargs.get('search_uncategorized')
})
@@ -724,7 +733,7 @@ class WebsiteSlides(WebsiteProfile):
type='http', auth="public", website=True, sitemap=False)
def slide_get_pdf_content(self, slide):
response = werkzeug.wrappers.Response()
response.data = slide.datas and base64.b64decode(slide.datas) or b''
response.data = slide.binary_content and base64.b64decode(slide.binary_content) or b''
response.mimetype = 'application/pdf'
return response
@@ -1044,28 +1053,77 @@ class WebsiteSlides(WebsiteProfile):
# --------------------------------------------------
@http.route(['/slides/prepare_preview'], type='json', auth='user', methods=['POST'], website=True)
def prepare_preview(self, **data):
def prepare_preview(self, channel_id, slide_category, url=None):
""" Will attempt to fetch external metadata for this slide from the correct
source (YouTube, Google Drive, ...).
To take advantage of the slide business method, we create a temporary slide record before
fetching the metadata.
This allows a lot of code simplification, since we use "new", it will not created anything
in database. """
if not url:
return {}
Slide = request.env['slide.slide']
unused, document_id = Slide._find_document_data_from_url(data['url'])
preview = {}
if not document_id:
preview['error'] = _('Please enter valid youtube or google doc url')
return preview
existing_slide = Slide.search([('channel_id', '=', int(data['channel_id'])), ('document_id', '=', document_id)], limit=1)
if existing_slide:
preview['error'] = _('This video already exists in this channel on the following slide: %s', existing_slide.name)
return preview
values = Slide._parse_document_url(data['url'], only_preview_fields=True)
if values.get('error'):
preview['error'] = values['error']
return preview
return values
additional_values = {}
if slide_category == 'video':
identical_video = request.env['slide.slide']
existing_videos = Slide.search([
('channel_id', '=', int(channel_id)),
('slide_category', '=', 'video')
])
slide = Slide.new({
'channel_id': int(channel_id),
'name': 'memory_record_for_computed_fields',
'slide_category': 'video',
'url': url
})
if not slide.video_source_type:
slide.unlink()
return {'error': _("Could not find your video. Please check if your link is correct and if the video can be accessed.")}
if slide.video_source_type == 'youtube':
identical_video = existing_videos.filtered(
lambda existing_video: slide.youtube_id == existing_video.youtube_id)
elif slide.video_source_type == 'google_drive':
identical_video = existing_videos.filtered(
lambda existing_video: slide.google_drive_id == existing_video.google_drive_id)
elif slide.video_source_type == 'vimeo':
identical_video = existing_videos.filtered(
lambda existing_video: slide.vimeo_id == existing_video.vimeo_id)
if identical_video:
identical_video_name = identical_video[0].name
additional_values['info'] = _('This video already exists in this channel on the following content: %s', identical_video_name)
elif slide_category in ['document', 'infographic']:
slide = Slide.new({
'channel_id': int(channel_id),
'name': 'memory_record_for_computed_fields',
'slide_category': slide_category,
'source_type': 'external',
'url': url
})
if not slide.google_drive_id:
return {'error': _('Please enter valid Google Drive Link')}
slide_values, error = slide._fetch_external_metadata(image_url_only=True)
if error:
return {'error': error}
if additional_values:
slide_values.update(additional_values)
return slide_values
@http.route(['/slides/add_slide'], type='json', auth='user', methods=['POST'], website=True)
def create_slide(self, *args, **post):
# check the size only when we upload a file.
if post.get('datas'):
file_size = len(post['datas']) * 3 / 4 # base64
if post.get('binary_content'):
file_size = len(post['binary_content']) * 3 / 4 # base64
if (file_size / 1024.0 / 1024.0) > 25:
return {'error': _('File is too big. File size cannot exceed 25MB')}
@@ -1117,9 +1175,9 @@ class WebsiteSlides(WebsiteProfile):
channel._resequence_slides(slide, force_category=category)
redirect_url = "/slides/slide/%s" % (slide.id)
if channel.channel_type == "training" and not slide.slide_type == "webpage":
if channel.channel_type == "training" and slide.slide_category not in ["article", "quiz"]:
redirect_url = "/slides/%s" % (slug(channel))
if slide.slide_type == 'webpage':
if slide.slide_category == 'article':
redirect_url += "?enable_editor=1"
return {
'url': redirect_url,
@@ -1129,8 +1187,8 @@ class WebsiteSlides(WebsiteProfile):
}
def _get_valid_slide_post_values(self):
return ['name', 'url', 'tag_ids', 'slide_type', 'channel_id', 'is_preview',
'mime_type', 'datas', 'description', 'image_1920', 'is_published']
return ['name', 'url', 'video_url', 'document_google_url', 'image_google_url', 'tag_ids', 'slide_category', 'channel_id',
'is_preview', 'binary_content', 'description', 'image_1920', 'is_published', 'source_type']
@http.route(['/slides/tag/search_read'], type='json', auth='user', methods=['POST'], website=True)
def slide_tag_search_read(self, fields, domain):
@@ -4,7 +4,7 @@
<record id="slide_template_published" model="mail.template">
<field name="name">Slide Published</field>
<field name="model_id" ref="model_slide_slide"/>
<field name="subject">New {{ object.slide_type }} published on {{ object.channel_id.name }}</field>
<field name="subject">New {{ object.slide_category }} published on {{ object.channel_id.name }}</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
@@ -36,14 +36,14 @@
<record id="slide_template_shared" model="mail.template">
<field name="name">Slide Shared</field>
<field name="model_id" ref="model_slide_slide"/>
<field name="subject">{{ user.name }} shared a {{ object.slide_type }} with you!</field>
<field name="subject">{{ user.name }} shared a {{ object.slide_category }} with you!</field>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ ctx.get('email', '') }}</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
Hello<br/><br/>
<t t-out="user.name or ''">Mitchell Admin</t> shared the <t t-out="object.slide_type or ''">document</t> <strong t-out="object.name or ''">Trees</strong> with you!
<t t-out="user.name or ''">Mitchell Admin</t> shared the <t t-out="object.slide_category or ''">document</t> <strong t-out="object.name or ''">Trees</strong> with you!
<div style="margin: 16px 8px 16px 8px; text-align: center;">
<a t-att-href="(object.website_url + '?fullscreen=1') if ctx.get('fullscreen') else object.website_url">
<img t-att-alt="object.name" t-attf-src="{{ ctx.get('base_url') }}/web/image/slide.slide/{{ object.id }}/image_1024" style="height:auto; width:150px; margin: 16px;"/>
+34 -41
View File
@@ -6,9 +6,9 @@
<record id="slide_slide_demo_0_0" model="slide.slide">
<field name="name">Gardening: The Know-How</field>
<field name="sequence">1</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="binary_content" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="image_1920" type="base64" file="website_slides/static/src/img/channel-training-default.jpg"/>
<field name="slide_type">presentation</field>
<field name="slide_category">document</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_0_gard_0"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -29,7 +29,7 @@
<field name="name">Home Gardening</field>
<field name="sequence">2</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_gardening_1.jpg"/>
<field name="slide_type">infographic</field>
<field name="slide_category">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_0_gard_0"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -43,7 +43,7 @@
<field name="name">Mighty Carrots</field>
<field name="sequence">3</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_gardening_2.jpg"/>
<field name="slide_type">infographic</field>
<field name="slide_category">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_0_gard_0"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -56,9 +56,9 @@
<record id="slide_slide_demo_0_3" model="slide.slide">
<field name="name">How to Grow and Harvest The Best Strawberries | Basics</field>
<field name="sequence">4</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="binary_content" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_thumb_l0JZ25VvbwE.jpg"/>
<field name="slide_type">document</field>
<field name="slide_category">document</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_0_gard_0"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -72,7 +72,7 @@
<field name="name">Test your knowledge</field>
<field name="sequence">5</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_owl.jpg"/>
<field name="slide_type">quiz</field>
<field name="slide_category">quiz</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_0_gard_0"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -149,7 +149,7 @@
<field name="name">Tree Infographic</field>
<field name="sequence">1</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_tree_infographic_1.jpg"/>
<field name="slide_type">infographic</field>
<field name="slide_category">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -163,7 +163,7 @@
<field name="name">Interesting Tree Facts</field>
<field name="sequence">2</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_tree_infographic_2.jpg"/>
<field name="slide_type">infographic</field>
<field name="slide_category">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -177,7 +177,7 @@
<field name="name">Energy Efficiency Facts</field>
<field name="sequence">3</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_tree_infographic_3.jpg"/>
<field name="slide_type">infographic</field>
<field name="slide_category">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -211,8 +211,7 @@
<field name="sequence">5</field>
<field name="url">https://www.youtube.com/watch?v=QYmgrw0PgLU</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_thumb_QYmgrw0PgLU.jpg"/>
<field name="document_id">QYmgrw0PgLU</field>
<field name="slide_type">video</field>
<field name="slide_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -226,7 +225,7 @@
<field name="name">A little chat with Harry Potted</field>
<field name="sequence">6</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_tree_img_1.jpg"/>
<field name="slide_type">webpage</field>
<field name="slide_category">article</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="html_content" type="html">
<section class="s_cover parallax bg-black-50 pt16 pb16" data-scroll-background-ratio="0" style="background-image: none;" data-snippet="s_cover">
@@ -327,9 +326,9 @@
<record id="slide_slide_demo_1_5" model="slide.slide">
<field name="name">3 Main Methodologies</field>
<field name="sequence">6</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="binary_content" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="image_1920" type="base64" file="website_slides/static/src/img/channel-training-default.jpg"/>
<field name="slide_type">presentation</field>
<field name="slide_category">document</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -344,8 +343,7 @@
<field name="sequence">7</field>
<field name="url">https://www.youtube.com/watch?v=l0JZ25VvbwE</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_thumb_l0JZ25VvbwE.jpg"/>
<field name="document_id">l0JZ25VvbwE</field>
<field name="slide_type">video</field>
<field name="slide_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -378,9 +376,9 @@
<record id="slide_slide_demo_2_0" model="slide.slide">
<field name="name">Main Trees Categories</field>
<field name="sequence">1</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="binary_content" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_tree_img_2.jpg"/>
<field name="slide_type">presentation</field>
<field name="slide_category">document</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -464,7 +462,7 @@
<field name="name">A Mighty Forest from Ages</field>
<field name="sequence">2</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_tree_img_3.jpg"/>
<field name="slide_type">webpage</field>
<field name="slide_category">article</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
<field name="html_content" type="html">
<section class="s_cover parallax bg-black-50 pt16 pb16" data-scroll-background-ratio="0" style="background-image: none;" data-snippet="s_cover">
@@ -512,8 +510,7 @@
<field name="sequence">3</field>
<field name="url">https://www.youtube.com/watch?v=ebBez6bcSEc</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_thumb_ebBez6bcSEc.jpg"/>
<field name="document_id">ebBez6bcSEc</field>
<field name="slide_type">video</field>
<field name="slide_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -527,9 +524,9 @@
<record id="slide_slide_demo_2_3" model="slide.slide">
<field name="name">Wood Characteristics</field>
<field name="sequence">5</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="binary_content" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="image_1920" type="base64" file="website_slides/static/src/img/channel-documentation-default.jpg"/>
<field name="slide_type">presentation</field>
<field name="slide_category">document</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -561,7 +558,7 @@
<field name="name">Comparing Hardness of Wood Species</field>
<field name="sequence">2</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_wood_infographic_1.jpg"/>
<field name="slide_type">infographic</field>
<field name="slide_category">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_3_furn0"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -576,8 +573,7 @@
<field name="sequence">3</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_thumb_PYr1rK8pS30.jpg"/>
<field name="url">https://www.youtube.com/watch?v=PYr1rK8pS30</field>
<field name="document_id">PYr1rK8pS30</field>
<field name="slide_type">video</field>
<field name="slide_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_3_furn0"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -609,7 +605,7 @@
<field name="name">Foreword</field>
<field name="sequence">1</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_furniture_2.jpg"/>
<field name="slide_type">webpage</field>
<field name="slide_category">article</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_4_furn1"/>
<field name="html_content" type="html">
<section class="s_cover parallax bg-black-50 pt16 pb16" data-scroll-background-ratio="0" style="background-image: none;" data-snippet="s_cover">
@@ -655,8 +651,7 @@
<field name="sequence">2</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_thumb_bvSe6r5BpaY.jpg"/>
<field name="url">https://www.youtube.com/watch?v=bvSe6r5BpaY</field>
<field name="document_id">bvSe6r5BpaY</field>
<field name="slide_type">video</field>
<field name="slide_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_4_furn1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=3)"/>
@@ -670,7 +665,7 @@
<field name="name">Drawing 1</field>
<field name="sequence">11</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_furniture_img.jpg"/>
<field name="slide_type">infographic</field>
<field name="slide_category">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_4_furn1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=4)"/>
@@ -684,7 +679,7 @@
<field name="name">Drawing 2</field>
<field name="sequence">12</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_furniture_img_1.jpg"/>
<field name="slide_type">infographic</field>
<field name="slide_category">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_4_furn1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=4)"/>
@@ -697,9 +692,9 @@
<record id="slide_slide_demo_4_12" model="slide.slide">
<field name="name">Presentation</field>
<field name="sequence">13</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="binary_content" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="image_1920" type="base64" file="website_slides/static/src/img/channel-documentation-default.jpg"/>
<field name="slide_type">document</field>
<field name="slide_category">document</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_4_furn1"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=4)"/>
@@ -739,9 +734,9 @@
<record id="slide_slide_demo_5_0" model="slide.slide">
<field name="name">Unforgettable Tools</field>
<field name="sequence">1</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="binary_content" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="image_1920" type="base64" file="website_slides/static/src/img/channel-training-default.jpg"/>
<field name="slide_type">presentation</field>
<field name="slide_category">document</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -768,8 +763,7 @@
<field name="sequence">2</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_thumb_5WMqwTnZ-qs.jpg"/>
<field name="url">https://www.youtube.com/watch?v=5WMqwTnZ-qs</field>
<field name="document_id">5WMqwTnZ-qs</field>
<field name="slide_type">video</field>
<field name="slide_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -783,8 +777,7 @@
<field name="sequence">4</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_thumb_grrXe1QZNzQ.jpg"/>
<field name="url">https://www.youtube.com/watch?v=grrXe1QZNzQ</field>
<field name="document_id">grrXe1QZNzQ</field>
<field name="slide_type">video</field>
<field name="slide_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
@@ -797,7 +790,7 @@
<field name="name">Test your knowledge !</field>
<field name="sequence">6</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_owl.jpg"/>
<field name="slide_type">quiz</field>
<field name="slide_category">quiz</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
<field name="is_published" eval="True"/>
<field name="date_published" eval="datetime.now() - timedelta(days=8)"/>
+19 -20
View File
@@ -183,11 +183,10 @@ class Channel(models.Model):
)
promoted_slide_id = fields.Many2one('slide.slide', string='Promoted Slide')
access_token = fields.Char("Security Token", copy=False, default=_default_access_token)
nbr_presentation = fields.Integer('Presentations', compute='_compute_slides_statistics', store=True)
nbr_document = fields.Integer('Documents', compute='_compute_slides_statistics', store=True)
nbr_video = fields.Integer('Videos', compute='_compute_slides_statistics', store=True)
nbr_infographic = fields.Integer('Infographics', compute='_compute_slides_statistics', store=True)
nbr_webpage = fields.Integer("Webpages", compute='_compute_slides_statistics', store=True)
nbr_article = fields.Integer("Articles", compute='_compute_slides_statistics', store=True)
nbr_quiz = fields.Integer("Number of Quizs", compute='_compute_slides_statistics', store=True)
total_slides = fields.Integer('Number of Contents', compute='_compute_slides_statistics', store=True)
total_views = fields.Integer('Visits', compute='_compute_slides_statistics', store=True)
@@ -300,18 +299,18 @@ class Channel(models.Model):
channel.slide_category_ids = channel.slide_ids.filtered(lambda slide: slide.is_category)
channel.slide_content_ids = channel.slide_ids - channel.slide_category_ids
@api.depends('slide_ids.slide_type', 'slide_ids.is_published', 'slide_ids.completion_time',
@api.depends('slide_ids.slide_category', 'slide_ids.is_published', 'slide_ids.completion_time',
'slide_ids.likes', 'slide_ids.dislikes', 'slide_ids.total_views', 'slide_ids.is_category', 'slide_ids.active')
def _compute_slides_statistics(self):
default_vals = dict(total_views=0, total_votes=0, total_time=0, total_slides=0)
keys = ['nbr_%s' % slide_type for slide_type in self.env['slide.slide']._fields['slide_type'].get_values(self.env)]
keys = ['nbr_%s' % slide_category for slide_category in self.env['slide.slide']._fields['slide_category'].get_values(self.env)]
default_vals.update(dict((key, 0) for key in keys))
result = dict((cid, dict(default_vals)) for cid in self.ids)
read_group_res = self.env['slide.slide'].read_group(
[('active', '=', True), ('is_published', '=', True), ('channel_id', 'in', self.ids), ('is_category', '=', False)],
['channel_id', 'slide_type', 'likes', 'dislikes', 'total_views', 'completion_time'],
groupby=['channel_id', 'slide_type'],
['channel_id', 'slide_category', 'likes', 'dislikes', 'total_views', 'completion_time'],
groupby=['channel_id', 'slide_category'],
lazy=False)
for res_group in read_group_res:
cid = res_group['channel_id'][0]
@@ -320,25 +319,25 @@ class Channel(models.Model):
result[cid]['total_votes'] -= res_group.get('dislikes', 0)
result[cid]['total_time'] += res_group.get('completion_time', 0)
type_stats = self._compute_slides_statistics_type(read_group_res)
for cid, cdata in type_stats.items():
category_stats = self._compute_slides_statistics_category(read_group_res)
for cid, cdata in category_stats.items():
result[cid].update(cdata)
for record in self:
record.update(result.get(record.id, default_vals))
def _compute_slides_statistics_type(self, read_group_res):
""" Compute statistics based on all existing slide types """
slide_types = self.env['slide.slide']._fields['slide_type'].get_values(self.env)
keys = ['nbr_%s' % slide_type for slide_type in slide_types]
def _compute_slides_statistics_category(self, read_group_res):
""" Compute statistics based on all existing slide categories """
slide_categories = self.env['slide.slide']._fields['slide_category'].get_values(self.env)
keys = ['nbr_%s' % slide_category for slide_category in slide_categories]
result = dict((cid, dict((key, 0) for key in keys + ['total_slides'])) for cid in self.ids)
for res_group in read_group_res:
cid = res_group['channel_id'][0]
slide_type = res_group.get('slide_type')
if slide_type:
slide_type_count = res_group.get('__count', 0)
result[cid]['nbr_%s' % slide_type] = slide_type_count
result[cid]['total_slides'] += slide_type_count
slide_category = res_group.get('slide_category')
if slide_category:
slide_category_count = res_group.get('__count', 0)
result[cid]['nbr_%s' % slide_category] = slide_category_count
result[cid]['total_slides'] += slide_category_count
return result
def _compute_rating_stats(self):
@@ -847,7 +846,7 @@ class Channel(models.Model):
with_date = options['displayDetail']
my = options.get('my')
search_tags = options.get('tag')
slide_type = options.get('slide_type')
slide_category = options.get('slide_category')
domain = [website.website_domain()]
if my:
domain.append([('partner_ids', '=', self.env.user.partner_id.id)])
@@ -865,8 +864,8 @@ class Channel(models.Model):
# OR inside a group, AND between groups.
for group in grouped_tags:
domain.append([('tag_ids', 'in', [tag.id for tag in grouped_tags[group]])])
if slide_type and 'nbr_%s' % slide_type in self:
domain.append([('nbr_%s' % slide_type, '>', 0)])
if slide_category and 'nbr_%s' % slide_category in self:
domain.append([('nbr_%s' % slide_category, '>', 0)])
search_fields = ['name']
fetch_fields = ['name', 'website_url']
mapping = {
+534 -226
View File
@@ -4,23 +4,23 @@
import base64
import datetime
import io
import logging
import re
import requests
import PyPDF2
import json
from dateutil.relativedelta import relativedelta
from markupsafe import Markup
from PIL import Image
from werkzeug import urls
from odoo import api, fields, models, _
from odoo.addons.http_routing.models.ir_http import slug
from odoo.exceptions import UserError, AccessError
from odoo.addons.http_routing.models.ir_http import slug, url_for
from odoo.exceptions import RedirectWarning, UserError, AccessError
from odoo.http import request
from odoo.addons.http_routing.models.ir_http import url_for
from odoo.tools import html2plaintext, sql
_logger = logging.getLogger(__name__)
class SlidePartnerRelation(models.Model):
_name = 'slide.slide.partner'
@@ -88,8 +88,13 @@ class Slide(models.Model):
}
_order = 'sequence asc, is_category asc, id asc'
YOUTUBE_VIDEO_ID_REGEX = r'^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*'
GOOGLE_DRIVE_DOCUMENT_ID_REGEX = r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)'
VIMEO_VIDEO_ID_REGEX = r'\/\/(player.)?vimeo.com\/(?:[a-z]*\/)*([0-9]{6,11})\/?([0-9a-z]{6,11})?[?]?.*'
# description
name = fields.Char('Title', required=True, translate=True)
image_1920 = fields.Image(compute="_compute_image_1920", store=True, readonly=False) # image.mixin override
active = fields.Boolean(default=True, tracking=100)
sequence = fields.Integer('Sequence', default=0)
user_id = fields.Many2one('res.users', string='Uploaded by', default=lambda self: self.env.uid)
@@ -118,23 +123,60 @@ class Slide(models.Model):
quiz_third_attempt_reward = fields.Integer("Reward: third attempt", default=5,)
quiz_fourth_attempt_reward = fields.Integer("Reward: every attempt after the third try", default=2)
# content
slide_type = fields.Selection([
('infographic', 'Infographic'),
('webpage', 'Web Page'),
('presentation', 'Presentation'),
slide_category = fields.Selection([
('infographic', 'Image'),
('article', 'Article'),
('document', 'Document'),
('video', 'Video'),
('quiz', "Quiz")],
string='Type', required=True,
default='document',
help="The document type will be set automatically based on the document URL and properties (e.g. height and width for presentation and document).")
datas = fields.Binary('File', attachment=True)
url = fields.Char('Document URL', help="Youtube or Google Document URL")
document_id = fields.Char('Document ID', help="Youtube or Google Document ID")
string='Category', required=True,
default='document')
source_type = fields.Selection([
('local_file', 'Upload from Device'),
('external', 'Retrieve from Google Drive')],
default='local_file', required=True)
# generic
url = fields.Char('External URL', help="URL of the Google Drive file or URL of the YouTube video")
binary_content = fields.Binary('File', attachment=True)
slide_resource_ids = fields.One2many('slide.slide.resource', 'slide_id', string="Additional Resource for this slide")
slide_resource_downloadable = fields.Boolean('Allow Download', default=True, help="Allow the user to download the content of the slide.")
mime_type = fields.Char('Mime-type')
html_content = fields.Html("HTML Content", help="Custom HTML content for slides of type 'Web Page'.", translate=True, sanitize_attributes=False, sanitize_form=False)
slide_resource_downloadable = fields.Boolean('Allow Download', default=False, help="Allow the user to download the content of the slide.")
# google
google_drive_id = fields.Char('Google Drive ID of the external URL', compute='_compute_google_drive_id')
# content - webpage
html_content = fields.Html("HTML Content", help="Custom HTML content for slides of category 'Article'.", translate=True, sanitize_attributes=False, sanitize_form=False)
# content - images
image_binary_content = fields.Binary('Image Content', related='binary_content', readonly=False,
help="Used to filter file input to images only")
image_google_url = fields.Char('Image Link', related='url', readonly=False,
help="Link of the image (we currently only support Google Drive as source)")
# content - documents
slide_type = fields.Selection([
('image', 'Image'),
('article', 'Article'),
('quiz', 'Quiz'),
('pdf', 'PDF'),
('sheet', 'Sheet (Excel, Google Sheet, ...)'),
('doc', 'Document (Word, Google Doc, ...)'),
('slides', 'Slides (PowerPoint, Google Slides, ...)'),
('youtube_video', 'YouTube Video'),
('google_drive_video', 'Google Drive Video'),
('vimeo_video', 'Vimeo Video')],
string="Slide Type", compute='_compute_slide_type', store=True, readonly=False,
help="Subtype of the slide category, allows more precision on the actual file type / source type.")
document_google_url = fields.Char('Document Link', related='url', readonly=False,
help="Link of the document (we currently only support Google Drive as source)")
document_binary_content = fields.Binary('PDF Content', related='binary_content', readonly=False,
help="Used to filter file input to PDF only")
# content - videos
video_url = fields.Char('Video Link', related='url', readonly=False,
help="Link of the video (we support YouTube, Google Drive and Vimeo as sources)")
video_source_type = fields.Selection([
('youtube', 'YouTube'),
('google_drive', 'Google Drive'),
('vimeo', 'Vimeo')],
string='Video Source', compute="_compute_video_source_type")
youtube_id = fields.Char('Video YouTube ID', compute='_compute_youtube_id')
vimeo_id = fields.Char('Video Vimeo ID', compute='_compute_vimeo_id')
# website
website_id = fields.Many2one(related='channel_id.website_id', readonly=True)
date_published = fields.Datetime('Publish Date', readonly=True, tracking=False)
@@ -156,20 +198,27 @@ class Slide(models.Model):
channel_type = fields.Selection(related="channel_id.channel_type", string="Channel type")
channel_allow_comment = fields.Boolean(related="channel_id.allow_comment", string="Allows comment")
# Statistics in case the slide is a category
nbr_presentation = fields.Integer("Number of Presentations", compute='_compute_slides_statistics', store=True)
nbr_document = fields.Integer("Number of Documents", compute='_compute_slides_statistics', store=True)
nbr_video = fields.Integer("Number of Videos", compute='_compute_slides_statistics', store=True)
nbr_infographic = fields.Integer("Number of Infographics", compute='_compute_slides_statistics', store=True)
nbr_webpage = fields.Integer("Number of Webpages", compute='_compute_slides_statistics', store=True)
nbr_infographic = fields.Integer("Number of Images", compute='_compute_slides_statistics', store=True)
nbr_article = fields.Integer("Number of Articles", compute='_compute_slides_statistics', store=True)
nbr_quiz = fields.Integer("Number of Quizs", compute="_compute_slides_statistics", store=True)
total_slides = fields.Integer(compute='_compute_slides_statistics', store=True)
is_published = fields.Boolean(tracking=1)
website_published = fields.Boolean(tracking=False)
_sql_constraints = [
('exclusion_html_content_and_url', "CHECK(html_content IS NULL OR url IS NULL)", "A slide is either filled with a document url or HTML content. Not both.")
('exclusion_html_content_and_url', "CHECK(html_content IS NULL OR url IS NULL)", "A slide is either filled with a url or HTML content. Not both.")
]
@api.depends('slide_category', 'source_type', 'image_binary_content')
def _compute_image_1920(self):
for slide in self:
if slide.slide_category == 'infographic' and slide.source_type == 'local_file' and slide.image_binary_content:
slide.image_1920 = slide.image_binary_content
elif not slide.image_1920:
slide.image_1920 = False
@api.depends('date_published', 'is_published')
def _compute_is_new_slide(self):
for slide in self:
@@ -270,37 +319,66 @@ class Slide(models.Model):
for slide in self:
slide.embed_count = mapped_data.get(slide.id, 0)
@api.depends('slide_ids.sequence', 'slide_ids.slide_type', 'slide_ids.is_published', 'slide_ids.is_category')
@api.depends('slide_ids.sequence', 'slide_ids.slide_category', 'slide_ids.is_published', 'slide_ids.is_category')
def _compute_slides_statistics(self):
# Do not use dict.fromkeys(self.ids, dict()) otherwise it will use the same dictionnary for all keys.
# Therefore, when updating the dict of one key, it updates the dict of all keys.
keys = ['nbr_%s' % slide_type for slide_type in self.env['slide.slide']._fields['slide_type'].get_values(self.env)]
keys = ['nbr_%s' % slide_category for slide_category in self.env['slide.slide']._fields['slide_category'].get_values(self.env)]
default_vals = dict((key, 0) for key in keys + ['total_slides'])
res = self.env['slide.slide'].read_group(
[('is_published', '=', True), ('category_id', 'in', self.ids), ('is_category', '=', False)],
['category_id', 'slide_type'], ['category_id', 'slide_type'],
['category_id', 'slide_category'], ['category_id', 'slide_category'],
lazy=False)
type_stats = self._compute_slides_statistics_type(res)
category_stats = self._compute_slides_statistics_category(res)
for record in self:
record.update(type_stats.get(record._origin.id, default_vals))
record.update(category_stats.get(record._origin.id, default_vals))
def _compute_slides_statistics_type(self, read_group_res):
""" Compute statistics based on all existing slide types """
slide_types = self.env['slide.slide']._fields['slide_type'].get_values(self.env)
keys = ['nbr_%s' % slide_type for slide_type in slide_types]
def _compute_slides_statistics_category(self, read_group_res):
""" Compute statistics based on all existing slide categories """
slide_categories = self.env['slide.slide']._fields['slide_category'].get_values(self.env)
keys = ['nbr_%s' % slide_category for slide_category in slide_categories]
result = dict((cid, dict((key, 0) for key in keys + ['total_slides'])) for cid in self.ids)
for res_group in read_group_res:
cid = res_group['category_id'][0]
slide_type = res_group.get('slide_type')
if slide_type:
slide_type_count = res_group.get('__count', 0)
result[cid]['nbr_%s' % slide_type] = slide_type_count
result[cid]['total_slides'] += slide_type_count
slide_category = res_group.get('slide_category')
if slide_category:
slide_category_count = res_group.get('__count', 0)
result[cid]['nbr_%s' % slide_category] = slide_category_count
result[cid]['total_slides'] += slide_category_count
return result
@api.depends('slide_category', 'source_type', 'video_source_type')
def _compute_slide_type(self):
""" For 'local content' or specific slide categories, the slide type is directly derived
from the slide category.
For external content, the slide type is determined from the metadata and the mime_type.
(See #_fetch_google_drive_metadata() for more details)."""
for slide in self:
if slide.slide_category == 'document':
if slide.source_type == 'local_file':
slide.slide_type = 'pdf'
elif slide.slide_type not in ['pdf', 'sheet', 'doc', 'slides']:
slide.slide_type = False
elif slide.slide_category == 'infographic':
slide.slide_type = 'image'
elif slide.slide_category == 'article':
slide.slide_type = 'article'
elif slide.slide_category == 'quiz':
slide.slide_type = 'quiz'
elif slide.slide_category == 'video' and slide.video_source_type == 'youtube':
slide.slide_type = 'youtube_video'
elif slide.slide_category == 'video' and slide.video_source_type == 'google_drive':
slide.slide_type = 'google_drive_video'
elif slide.slide_category == 'video' and slide.video_source_type == 'vimeo':
slide.slide_type = 'vimeo_video'
else:
slide.slide_type = False
@api.depends('slide_partner_ids.partner_id')
@api.depends('uid')
def _compute_user_membership_id(self):
@@ -315,70 +393,137 @@ class Slide(models.Model):
self.env['slide.slide.partner']
)
@api.depends('document_id', 'slide_type', 'mime_type')
@api.depends('slide_category', 'google_drive_id', 'video_source_type', 'youtube_id')
def _compute_embed_code(self):
base_url = request and request.httprequest.url_root
for record in self:
embed_code_external = False
if not base_url:
base_url = record.get_base_url()
request_base_url = request.httprequest.url_root if request else False
for slide in self:
base_url = request_base_url or slide.get_base_url()
if base_url[-1] == '/':
base_url = base_url[:-1]
if record.datas and (not record.document_id or record.slide_type in ['document', 'presentation']):
slide_url = base_url + url_for('/slides/embed/%s?page=1' % record.id)
slide_url_external = base_url + url_for('/slides/embed_external/%s?page=1' % record.id)
embed_code = False
embed_code_external = False
if slide.slide_category == 'video':
if slide.video_source_type == 'youtube':
query_params = urls.url_parse(slide.video_url).query
query_params = query_params + '&theme=light' if query_params else 'theme=light'
embed_code = Markup('<iframe src="//www.youtube-nocookie.com/embed/%s?%s" allowFullScreen="true" frameborder="0"></iframe>') % (slide.youtube_id, query_params)
elif slide.video_source_type == 'google_drive':
embed_code = Markup('<iframe src="//drive.google.com/file/d/%s/preview" allowFullScreen="true" frameborder="0"></iframe>') % (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("""
<iframe src="https://player.vimeo.com/video/%s?h=%s&badge=0&amp;autopause=0&amp;player_id=0"
frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>""") % (
vimeo_id, vimeo_token)
else:
embed_code = Markup("""
<iframe src="https://player.vimeo.com/video/%s?badge=0&amp;autopause=0&amp;player_id=0"
frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>""") % (slide.vimeo_id)
elif slide.slide_category in ['infographic', 'document'] and slide.source_type == 'external' and slide.google_drive_id:
embed_code = Markup('<iframe src="//drive.google.com/file/d/%s/preview" allowFullScreen="true" frameborder="0"></iframe>') % (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('<iframe src="%s" class="o_wslides_iframe_viewer" allowFullScreen="true" height="%s" width="%s" frameborder="0"></iframe>')
record.embed_code = base_embed_code % (slide_url, 315, 420)
embed_code = base_embed_code % (slide_url, 315, 420)
embed_code_external = base_embed_code % (slide_url_external, 315, 420)
elif record.slide_type == 'video' and record.document_id:
if not record.mime_type:
# embed youtube video
query = urls.url_parse(record.url).query
query = query + '&theme=light' if query else 'theme=light'
record.embed_code = Markup('<iframe src="//www.youtube-nocookie.com/embed/%s?%s" allowFullScreen="true" frameborder="0"></iframe>') % (record.document_id, query)
slide.embed_code = embed_code
slide.embed_code_external = embed_code_external or embed_code
@api.depends('video_url')
def _compute_video_source_type(self):
for slide in self:
video_source_type = False
youtube_match = re.match(self.YOUTUBE_VIDEO_ID_REGEX, slide.video_url) if slide.video_url else False
if youtube_match and len(youtube_match.groups()) == 2 and len(youtube_match.group(2)) == 11:
video_source_type = 'youtube'
if slide.video_url and not video_source_type and re.match(self.GOOGLE_DRIVE_DOCUMENT_ID_REGEX, slide.video_url):
video_source_type = 'google_drive'
vimeo_match = re.search(self.VIMEO_VIDEO_ID_REGEX, slide.video_url) if slide.video_url else False
if not video_source_type and vimeo_match and len(vimeo_match.groups()) == 3:
video_source_type = 'vimeo'
slide.video_source_type = video_source_type
@api.depends('video_url', 'video_source_type')
def _compute_youtube_id(self):
for slide in self:
if slide.video_url and slide.video_source_type == 'youtube':
match = re.match(self.YOUTUBE_VIDEO_ID_REGEX, slide.video_url)
if match and len(match.groups()) == 2 and len(match.group(2)) == 11:
slide.youtube_id = match.group(2)
else:
# embed google doc video
record.embed_code = Markup('<iframe src="//drive.google.com/file/d/%s/preview" allowFullScreen="true" frameborder="0"></iframe>') % (record.document_id)
slide.youtube_id = False
else:
record.embed_code = False
slide.youtube_id = False
record.embed_code_external = embed_code_external or record.embed_code
@api.depends('video_url', 'video_source_type')
def _compute_vimeo_id(self):
for slide in self:
if slide.video_url and slide.video_source_type == 'vimeo':
match = re.search(self.VIMEO_VIDEO_ID_REGEX, slide.video_url)
if match and len(match.groups()) == 3:
if match.group(3):
# in case of privacy 'with URL only', vimeo adds a token after the video ID
# the share url is then 'vimeo_id/token'
# the token will be captured in the third group of the regex (if any)
slide.vimeo_id = '%s/%s' % (match.group(2), match.group(3))
else:
# regular video, we just capture the vimeo_id
slide.vimeo_id = match.group(2)
else:
slide.vimeo_id = False
@api.onchange('url')
@api.depends('url', 'document_google_url', 'image_google_url', 'video_url')
def _compute_google_drive_id(self):
""" Extracts the Google Drive ID from the url based on the slide category. """
for slide in self:
url = slide.url or slide.document_google_url or slide.image_google_url or slide.video_url
google_drive_id = False
if url:
match = re.match(self.GOOGLE_DRIVE_DOCUMENT_ID_REGEX, url)
if match and len(match.groups()) == 2:
google_drive_id = match.group(2)
slide.google_drive_id = google_drive_id
@api.onchange('url', 'document_google_url', 'image_google_url', 'video_url')
def _on_change_url(self):
self.ensure_one()
if self.url:
res = self._parse_document_url(self.url)
if res.get('error'):
raise UserError(res.get('error'))
values = res['values']
if not values.get('document_id'):
raise UserError(_('Please enter valid Youtube or Google Doc URL'))
for key, value in values.items():
self[key] = value
""" Keeping a 'onchange' because we want this behavior for the frontend.
Changing the document / video external URL will populate some metadata on the form view.
We only populate the field that are empty to avoid overriding user assigned values.
The slide metadata are also fetched in create / write overrides to ensure consistency. """
@api.onchange('datas')
def _on_change_datas(self):
""" For PDFs, we assume that it takes 5 minutes to read a page.
If the selected file is not a PDF, it is an image (You can
only upload PDF or Image file) then the slide_type is changed
into infographic and the uploaded dataS is transfered to the
image field. (It avoids the infinite loading in PDF viewer)"""
if self.datas:
data = base64.b64decode(self.datas)
if data.startswith(b'%PDF-'):
pdf = PyPDF2.PdfFileReader(io.BytesIO(data), overwriteWarnings=False, strict=False)
try:
pdf.getNumPages()
except PyPDF2.utils.PdfReadError:
return
self.completion_time = (5 * len(pdf.pages)) / 60
else:
self.slide_type = 'infographic'
self.image_1920 = self.datas
self.datas = None
self.ensure_one()
if self.url or self.document_google_url or self.image_google_url or self.video_url:
slide_metadata, _error = self._fetch_external_metadata()
if slide_metadata:
self.update({
key: value
for key, value in slide_metadata.items()
if not self[key]
})
@api.onchange('document_binary_content')
def _on_change_document_binary_content(self):
if self.slide_category == 'document' and self.source_type == 'local_file' and self.document_binary_content:
completion_time = self._get_completion_time_pdf(base64.b64decode(self.document_binary_content))
if completion_time:
self.completion_time = completion_time
@api.onchange('slide_category')
def _on_change_slide_category(self):
""" Prevents mis-match when ones uploads an image and then a pdf without saving the form. """
if self.slide_category != 'infographic' and self.image_binary_content:
self.image_binary_content = False
elif self.slide_category != 'document' and self.document_binary_content:
self.document_binary_content = False
@api.depends('name', 'channel_id.website_id.domain')
def _compute_website_url(self):
@@ -409,29 +554,32 @@ class Slide(models.Model):
# 'website_published' is handled by mixin
values['date_published'] = False
if values.get('slide_type') == 'infographic' and not values.get('image_1920'):
values['image_1920'] = values['datas']
if values.get('is_category'):
values['is_preview'] = True
values['is_published'] = True
if values.get('is_published') and not values.get('date_published'):
values['date_published'] = datetime.datetime.now()
if values.get('url') and not values.get('document_id'):
doc_data = self._parse_document_url(values['url']).get('values', dict())
for key, value in doc_data.items():
values.setdefault(key, value)
slide = super(Slide, self).create(values)
# avoid fetching external metadata when installing the module (i.e. for demo data)
# we also support a context key if you don't want to fetch the metadata when creating a slide
if any(values.get(url_param) for url_param in ['url', 'video_url', 'document_google_url', 'image_google_url']) \
and not self.env.context.get('install_mode') \
and not self.env.context.get('website_slides_skip_fetch_metadata'):
slide_metadata, _error = slide._fetch_external_metadata()
if slide_metadata:
# only update keys that are not set in the incoming values
slide.update({key: value for key, value in slide_metadata.items() if key not in values.keys()})
if not 'completion_time' not in values:
slide._on_change_document_binary_content()
if slide.is_published and not slide.is_category:
slide._post_publication()
return slide
def write(self, values):
if values.get('url') and values['url'] != self.url:
doc_data = self._parse_document_url(values['url']).get('values', dict())
for key, value in doc_data.items():
values.setdefault(key, value)
if values.get('is_category'):
values['is_preview'] = True
values['is_published'] = True
@@ -441,6 +589,20 @@ class Slide(models.Model):
self.date_published = datetime.datetime.now()
self._post_publication()
# avoid fetching external metadata when installing the module (i.e. for demo data)
# we also support a context key if you don't want to fetch the metadata when modifying a slide
if any(values.get(url_param) for url_param in ['url', 'video_url', 'document_google_url', 'image_google_url']) \
and not self.env.context.get('install_mode') \
and not self.env.context.get('website_slides_skip_fetch_metadata'):
slide_metadata, _error = self._fetch_external_metadata()
if slide_metadata:
# only update keys that are not set in the incoming values and for which we don't have a value yet
self.update({
key: value
for key, value in slide_metadata.items()
if key not in values.keys() and not any(slide[key] for slide in self)
})
if 'is_published' in values or 'active' in values:
# if the slide is published/unpublished, recompute the completion for the partners
self.slide_partner_ids._set_completed_callback()
@@ -741,165 +903,297 @@ class Slide(models.Model):
# Parsing methods
# --------------------------------------------------
@api.model
def _fetch_data(self, base_url, params, content_type=False):
result = {'values': dict()}
def _fetch_external_metadata(self, image_url_only=False):
self.ensure_one()
slide_metadata = {}
error = False
if self.slide_category == 'video' and self.video_source_type == 'youtube':
slide_metadata, error = self._fetch_youtube_metadata(image_url_only)
elif self.slide_category == 'video' and self.video_source_type == 'google_drive':
slide_metadata, error = self._fetch_google_drive_metadata(image_url_only)
elif self.slide_category == 'video' and self.video_source_type == 'vimeo':
slide_metadata, error = self._fetch_vimeo_metadata(image_url_only)
elif self.slide_category in ['document', 'infographic'] and self.source_type == 'external':
# external documents & google drive videos share the same method currently
slide_metadata, error = self._fetch_google_drive_metadata(image_url_only)
return slide_metadata, error
def _fetch_youtube_metadata(self, image_url_only=False):
""" Fetches video metadata from the YouTube API.
Returns a dict containing video metadata with the following keys (matching slide.slide fields):
- 'name' matching the video title
- 'description' matching the video description
- 'image_1920' binary data of the video thumbnail
OR 'image_url' containing an external link to the thumbnail when 'image_url_only' param is True
- 'completion_time' matching the video duration
The received duration is under a special format (e.g: PT1M21S15, meaning 1h 21m 15s).
:param image_url_only: if True, will return 'image_url' instead of binary data
Typically used when displaying a slide preview to the end user.
:return a tuple (values, error) containing the values of the slide and a potential error
(e.g: 'Video could not be found') """
self.ensure_one()
google_app_key = self.env['website'].get_current_website().website_slide_google_app_key
error_message = False
try:
response = requests.get(base_url, timeout=3, params=params)
response = requests.get(
'https://www.googleapis.com/youtube/v3/videos',
timeout=3,
params={
'fields': 'items(id,snippet,contentDetails)',
'id': self.youtube_id,
'key': google_app_key,
'part': 'snippet,contentDetails'
}
)
response.raise_for_status()
if content_type == 'json':
result['values'] = response.json()
elif content_type in ('image', 'pdf'):
result['values'] = base64.b64encode(response.content)
else:
result['values'] = response.content
except requests.exceptions.HTTPError as e:
result['error'] = e.response.content
error_message = e.response.content
if 'application/json' in e.response.headers.get('content-type'):
json_response = e.response.json()
if json_response.get('error', {}).get('code') == 404:
return {}, _('Your video could not be found on YouTube, please check the link and/or privacy settings')
except requests.exceptions.ConnectionError as e:
result['error'] = str(e)
return result
error_message = str(e)
def _find_document_data_from_url(self, url):
url_obj = urls.url_parse(url)
if url_obj.ascii_host == 'youtu.be':
return ('youtube', url_obj.path[1:] if url_obj.path else False)
elif url_obj.ascii_host in ('youtube.com', 'www.youtube.com', 'm.youtube.com', 'www.youtube-nocookie.com'):
v_query_value = url_obj.decode_query().get('v')
if v_query_value:
return ('youtube', v_query_value)
split_path = url_obj.path.split('/')
if len(split_path) >= 3 and split_path[1] in ('v', 'embed'):
return ('youtube', split_path[2])
if not error_message:
response = response.json()
if response.get('error'):
error_message = response.get('error', {}).get('errors', [{}])[0].get('reason')
expr = re.compile(r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)')
arg = expr.match(url)
document_id = arg and arg.group(2) or False
if document_id:
return ('google', document_id)
if not response.get('items'):
error_message = _('Your video could not be found on YouTube, please check the link and/or privacy settings')
return (None, False)
def _parse_document_url(self, url, only_preview_fields=False):
document_source, document_id = self._find_document_data_from_url(url)
if document_source and hasattr(self, '_parse_%s_document' % document_source):
return getattr(self, '_parse_%s_document' % document_source)(document_id, only_preview_fields)
return {'error': _('Unknown document')}
def _parse_youtube_document(self, document_id, only_preview_fields):
""" If we receive a duration (YT video), we use it to determine the slide duration.
The received duration is under a special format (e.g: PT1M21S15, meaning 1h 21m 15s). """
key = self.env['website'].get_current_website().website_slide_google_app_key
fetch_res = self._fetch_data('https://www.googleapis.com/youtube/v3/videos', {'id': document_id, 'key': key, 'part': 'snippet,contentDetails', 'fields': 'items(id,snippet,contentDetails)'}, 'json')
if fetch_res.get('error'):
return {'error': self._extract_google_error_message(fetch_res.get('error'))}
values = {'slide_type': 'video', 'document_id': document_id}
items = fetch_res['values'].get('items')
if not items:
return {'error': _('Please enter valid Youtube or Google Doc URL')}
youtube_values = items[0]
if error_message:
_logger.warning('Could not fetch YouTube metadata: %s', error_message)
return {}, error_message
slide_metadata = {'slide_type': 'youtube_video'}
youtube_values = response.get('items')[0]
youtube_duration = youtube_values.get('contentDetails', {}).get('duration')
if youtube_duration:
parsed_duration = re.search(r'^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$', youtube_duration)
if parsed_duration:
values['completion_time'] = (int(parsed_duration.group(1) or 0)) + \
(int(parsed_duration.group(2) or 0) / 60) + \
(int(parsed_duration.group(3) or 0) / 3600)
slide_metadata['completion_time'] = (int(parsed_duration.group(1) or 0)) + \
(int(parsed_duration.group(2) or 0) / 60) + \
(int(parsed_duration.group(3) or 0) / 3600)
if youtube_values.get('snippet'):
snippet = youtube_values['snippet']
if only_preview_fields:
values.update({
'url_src': snippet['thumbnails']['high']['url'],
'title': snippet['title'],
'description': snippet['description']
})
return values
values.update({
slide_metadata.update({
'name': snippet['title'],
'image_1920': self._fetch_data(snippet['thumbnails']['high']['url'], {}, 'image')['values'],
'description': snippet['description'],
'mime_type': False,
})
return {'values': values}
def _extract_google_error_message(self, error):
"""
See here for Google error format
https://developers.google.com/drive/api/v3/handle-errors
"""
try:
error = json.loads(error)
error = (error.get('error', {}).get('errors', []) or [{}])[0].get('reason')
except json.decoder.JSONDecodeError:
error = str(error)
thumbnail_url = snippet['thumbnails']['high']['url']
if image_url_only:
slide_metadata['image_url'] = thumbnail_url
else:
slide_metadata['image_1920'] = base64.b64encode(
requests.get(thumbnail_url, timeout=3).content
)
if error == 'keyInvalid':
return _('Your Google API key is invalid, please update it in your settings.\nSettings > Website > Features > API Key')
return slide_metadata, None
return _('Could not fetch data from url. Document or access right not available:\n%s', error)
def _fetch_google_drive_metadata(self, image_url_only=False):
""" Fetches document / video metadata from the Google Drive API.
@api.model
def _parse_google_document(self, document_id, only_preview_fields):
def get_slide_type(vals):
# TDE FIXME: WTF ??
slide_type = 'presentation'
if vals.get('image_1920'):
image = Image.open(io.BytesIO(base64.b64decode(vals['image_1920'])))
width, height = image.size
if height > width:
return 'document'
return slide_type
Returns a dict containing metadata with the following keys (matching slide.slide fields):
- 'name' matching the external file title
- 'image_1920' binary data of the file thumbnail
OR 'image_url' containing an external link to the thumbnail when 'image_url_only' param is True
- 'completion_time' which is computed for 2 types of files:
- pdf files where we download the content and then use slide.slide#_get_completion_time_pdf()
- videos where we use the 'videoMediaMetadata' to extract the 'durationMillis'
:param image_url_only: if True, will return 'image_url' instead of binary data
Typically used when displaying a slide preview to the end user.
:return a tuple (values, error) containing the values of the slide and a potential error
(e.g: 'File could not be found') """
# Google drive doesn't use a simple API key to access the data, but requires an access
# token. However, this token is generated in module google_drive, which is not in the
# dependencies of website_slides. We still keep the 'key' parameter just in case, but that
# is probably useless.
params = {}
params['projection'] = 'BASIC'
if 'google.drive.config' in self.env:
access_token = self.env['google.drive.config'].get_access_token()
access_token = False
try:
access_token = self.env['google.drive.config'].get_access_token()
except (RedirectWarning, UserError):
pass # ignore and use the 'key' fallback
if access_token:
params['access_token'] = access_token
if not params.get('access_token'):
params['key'] = self.env['website'].get_current_website().website_slide_google_app_key
fetch_res = self._fetch_data('https://www.googleapis.com/drive/v2/files/%s' % document_id, params, "json")
if fetch_res.get('error'):
return {'error': self._extract_google_error_message(fetch_res.get('error'))}
error_message = False
try:
response = requests.get(
'https://www.googleapis.com/drive/v2/files/%s' % self.google_drive_id,
timeout=3,
params=params
)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
error_message = e.response.content
if 'application/json' in e.response.headers.get('content-type'):
json_response = e.response.json()
if json_response.get('error', {}).get('code') == 404:
# in case we don't find the file on GDrive, we want to give some feedback to our user
return {}, _('Your file could not be found on Google Drive, please check the link and/or privacy settings')
except requests.exceptions.ConnectionError as e:
error_message = str(e)
google_values = fetch_res['values']
if only_preview_fields:
return {
'url_src': google_values['thumbnailLink'],
'title': google_values['title'],
}
if not error_message:
response = response.json()
if response.get('error'):
error_message = response.get('error', {}).get('errors', [{}])[0].get('reason')
values = {
'name': google_values['title'],
'image_1920': self._fetch_data(google_values['thumbnailLink'].replace('=s220', ''), {}, 'image')['values'],
'mime_type': google_values['mimeType'],
'document_id': document_id,
if error_message:
_logger.warning('Could not fetch Google Drive metadata: %s', error_message)
return {}, error_message
google_drive_values = response
slide_metadata = {
'name': google_drive_values.get('title')
}
if google_values['mimeType'].startswith('video/'):
values['slide_type'] = 'video'
elif google_values['mimeType'].startswith('image/'):
values['datas'] = values['image_1920']
values['slide_type'] = 'infographic'
elif google_values['mimeType'].startswith('application/vnd.google-apps'):
values['slide_type'] = get_slide_type(values)
if 'exportLinks' in google_values:
values['datas'] = self._fetch_data(google_values['exportLinks']['application/pdf'], params, 'pdf')['values']
elif google_values['mimeType'] == 'application/pdf':
# TODO: Google Drive PDF document doesn't provide plain text transcript
values['datas'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values']
values['slide_type'] = get_slide_type(values)
return {'values': values}
if google_drive_values.get('thumbnailLink'):
# small trick, we remove '=s220' to get a higher definition
thumbnail_url = google_drive_values['thumbnailLink'].replace('=s220', '')
if image_url_only:
slide_metadata['image_url'] = thumbnail_url
else:
slide_metadata['image_1920'] = base64.b64encode(
requests.get(thumbnail_url, timeout=3).content
)
if self.slide_category == 'document':
sheet_mimetypes = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.google-apps.spreadsheet'
]
doc_mimetypes = [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.oasis.opendocument.text',
'application/vnd.google-apps.document'
]
slides_mimetypes = [
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.google-apps.presentation'
]
mime_type = google_drive_values.get('mimeType')
if mime_type == 'application/pdf':
slide_metadata['slide_type'] = 'pdf'
if google_drive_values.get('downloadUrl'):
# attempt to download PDF content to extract a completion_time based on the number of pages
try:
pdf_response = requests.get(google_drive_values.get('downloadUrl'), timeout=5)
completion_time = self._get_completion_time_pdf(pdf_response.content)
if completion_time:
slide_metadata['completion_time'] = completion_time
except Exception:
pass # fail silently as this is nice to have
elif mime_type in sheet_mimetypes:
slide_metadata['slide_type'] = 'sheet'
elif mime_type in doc_mimetypes:
slide_metadata['slide_type'] = 'doc'
elif mime_type in slides_mimetypes:
slide_metadata['slide_type'] = 'slides'
elif mime_type and mime_type.startswith('image/'):
# image and videos should be input using another "slide_category" but let's be nice and
# assign them a matching slide_type
slide_metadata['slide_type'] = 'image'
elif mime_type and mime_type.startswith('video/'):
slide_metadata['slide_type'] = 'google_drive_video'
elif self.slide_category == 'video':
completion_time = float(
google_drive_values.get('videoMediaMetadata', {}).get('durationMillis', 0)
) / (60 * 60 * 1000) # millis to hours conversion
if completion_time:
slide_metadata['completion_time'] = completion_time
return slide_metadata, None
def _fetch_vimeo_metadata(self, image_url_only=False):
""" Fetches video metadata from the Vimeo API.
See https://developer.vimeo.com/api/oembed/showcases for more information.
Returns a dict containing video metadata with the following keys (matching slide.slide fields):
- 'name' matching the video title
- 'description' matching the video description
- 'image_1920' binary data of the video thumbnail
OR 'image_url' containing an external link to the thumbnail when 'fetch_image' param is False
- 'completion_time' matching the video duration
:param image_url_only: if False, will return 'image_url' instead of binary data
Typically used when displaying a slide preview to the end user.
:return a tuple (values, error) containing the values of the slide and a potential error
(e.g: 'Video could not be found') """
self.ensure_one()
error_message = False
try:
response = requests.get(
'https://vimeo.com/api/oembed.json?%s' % urls.url_encode({'url': self.video_url}),
timeout=3
)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
error_message = e.response.content
if e.response.status_code == 404:
return {}, _('Your video could not be found on Vimeo, please check the link and/or privacy settings')
except requests.exceptions.ConnectionError as e:
error_message = str(e)
if not error_message and 'application/json' in response.headers.get('content-type'):
response = response.json()
if response.get('error'):
error_message = response.get('error', {}).get('errors', [{}])[0].get('reason')
if not response:
error_message = _('Please enter a valid Vimeo video link')
if error_message:
_logger.warning('Could not fetch Vimeo metadata: %s', error_message)
return {}, error_message
vimeo_values = response
slide_metadata = {'slide_type': 'vimeo_video'}
if vimeo_values.get('title'):
slide_metadata['name'] = vimeo_values.get('title')
if vimeo_values.get('description'):
slide_metadata['description'] = vimeo_values.get('description')
if vimeo_values.get('duration'):
# seconds to hours conversion
slide_metadata['completion_time'] = vimeo_values.get('duration') / (60 * 60)
thumbnail_url = vimeo_values.get('thumbnail_url')
if thumbnail_url:
if image_url_only:
slide_metadata['image_url'] = thumbnail_url
else:
slide_metadata['image_1920'] = base64.b64encode(
requests.get(thumbnail_url, timeout=3).content
)
return slide_metadata, None
def _default_website_meta(self):
res = super(Slide, self)._default_website_meta()
@@ -913,6 +1207,20 @@ class Slide(models.Model):
# Data / Misc
# ---------------------------------------------------------
def _get_completion_time_pdf(self, data_bytes):
""" For PDFs, we assume that it takes 5 minutes to read a page.
This method receives the data of the PDF as bytes. """
if data_bytes.startswith(b'%PDF-'):
try:
pdf = PyPDF2.PdfFileReader(io.BytesIO(data_bytes), overwriteWarnings=False)
return (5 * len(pdf.pages)) / 60
except Exception:
pass # as this is a nice to have, fail silently
return False
def get_backend_menu_id(self):
return self.env.ref('website_slides.website_slides_menu_root').id
@@ -942,9 +1250,9 @@ class Slide(models.Model):
}
def _search_render_results(self, fetch_fields, mapping, icon, limit):
icon_per_type = {
icon_per_category = {
'infographic': 'fa-file-picture-o',
'webpage': 'fa-file-text',
'article': 'fa-file-text',
'presentation': 'fa-file-pdf-o',
'document': 'fa-file-pdf-o',
'video': 'fa-play-circle',
@@ -953,7 +1261,7 @@ class Slide(models.Model):
}
results_data = super()._search_render_results(fetch_fields, mapping, icon, limit)
for slide, data in zip(self, results_data):
data['_fa'] = icon_per_type.get(slide.slide_type, 'fa-file-pdf-o')
data['_fa'] = icon_per_category.get(slide.slide_category, 'fa-file-pdf-o')
data['url'] = slide.website_url
data['course'] = _('Course: %s', slide.channel_id.name)
data['course_url'] = slide.channel_id.website_url
@@ -19,7 +19,7 @@ var CategoryAddDialog = Dialog.extend({
classes: 'btn-primary',
click: this._onClickFormSubmit.bind(this)
}, {
text: _t('Discard'),
text: _t('Back'),
close: true
}]
});
@@ -1,9 +1,10 @@
/** @odoo-module **/
/* global YT */
/* global YT, Vimeo */
import publicWidget from 'web.public.widget';
import { qweb as QWeb, _t } from 'web.core';
import { Markup } from 'web.utils';
import config from 'web.config';
import session from 'web.session';
@@ -30,8 +31,8 @@
* its end, and `slide_completed` when the player is at 30 sec before the
* end of the video (30 sec before is considered as completed).
*/
var VideoPlayer = publicWidget.Widget.extend({
template: 'website.slides.fullscreen.video',
var VideoPlayerYouTube = publicWidget.Widget.extend({
template: 'website.slides.fullscreen.video.youtube',
youtubeUrl: 'https://www.youtube.com/iframe_api',
init: function (parent, slide) {
@@ -129,6 +130,98 @@
},
});
/**
* This widget is responsible of loading the Vimeo video.
*
* Similarly to the YouTube implementation, the widget will trigger an event `change_slide` when
* the video is at its end, and `slide_completed` when the player is at 30 sec before the end of
* the video (30 sec before is considered as completed).
*
* See https://developer.vimeo.com/player/sdk/reference for all the API documentation.
*/
var VideoPlayerVimeo = publicWidget.Widget.extend({
template: 'website.slides.fullscreen.video.vimeo',
vimeoScriptUrl: 'https://player.vimeo.com/api/player.js',
init: function (parent, slide) {
this.slide = slide;
return this._super.apply(this, arguments);
},
/**
* Loads the Vimeo JS API that allows interfacing with the iframe viewer.
* (We only load the API if not already loaded).
*
* @returns {Promise}
*/
willStart: function () {
var self = this;
var vimeoAPIPromise = new Promise(function (resolve, reject) {
if ($(document).find('script[src="' + self.vimeoScriptUrl + '"]').length === 0) {
$.ajax({
url: self.vimeoScriptUrl,
dataType: 'script',
success: function () {resolve();}
});
} else {
resolve();
}
});
return Promise.all([this._super.apply(this, arguments), vimeoAPIPromise]);
},
start: function () {
return this._super.apply(arguments).then(this._setupVideoPlayer.bind(this));
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Instantiate the Vimeo player and register the various events.
*/
_setupVideoPlayer: async function () {
this.player = new Vimeo.Player(this.$('iframe')[0]);
this.videoDuration = await this.player.getDuration();
this.player.on('timeupdate', this._onVideoTimeUpdate.bind(this));
this.player.on('ended', this._onVideoEnded.bind(this));
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* When the player triggers the 'ended' event, we go to the next slide if there is one.
*
* See https://developer.vimeo.com/player/sdk/reference#ended for more information
*/
_onVideoEnded: function () {
if (this.slide.hasNext) {
this.trigger_up('slide_go_next', this.slide);
}
},
/**
* Every time the video changes position, both while viewing and also when seeking manually,
* Vimeo triggers this handy 'timeupdate' event.
* We use it to set the slide as completed as soon as we reach the end (30 last seconds).
*
* See https://developer.vimeo.com/player/sdk/reference#timeupdate for more information
*
* @param {Object} eventData the 'timeupdate' event data
*/
_onVideoTimeUpdate: async function (eventData) {
if (eventData.seconds > (this.videoDuration - 30)) {
if (!this.slide.hasQuestion && !this.slide.completed){
this.trigger_up('slide_to_complete', this.slide);
}
}
}
});
/**
* This widget is responsible of navigation for one slide to another:
@@ -225,7 +318,7 @@
//--------------------------------------------------------------------------
/**
* Handler called whenever the user clicks on a sub-quiz which is linked to a slide.
* This does NOT handle the case of a slide of type "quiz".
* This does NOT handle the case of a slide of category "quiz".
* By going through this handler, the widget will be able to determine that it has to render
* the associated quiz and not the main content.
*
@@ -455,7 +548,7 @@
// Private
//--------------------------------------------------------------------------
/**
* Fetches content with an rpc call for slides of type "webpage"
* Fetches content with an rpc call for slides of category "article"
*
* @private
*/
@@ -474,14 +567,14 @@
});
},
/**
* Fetches slide content depending on its type.
* Fetches slide content depending on its category.
* If the slide doesn't need to fetch any content, return a resolved deferred
*
* @private
*/
_fetchSlideContent: function (){
var slide = this.get('slide');
if (slide.type === 'webpage' && !slide.isQuiz) {
if (slide.category === 'article' && !slide.isQuiz) {
return this._fetchHtmlContent();
}
return Promise.resolve();
@@ -494,14 +587,14 @@
},
/**
* Extend the slide data list to add informations about rendering method, and other
* specific values according to their slide_type.
* specific values according to their slide_category.
*/
_preprocessSlideData: function (slidesDataList) {
slidesDataList.forEach(function (slideData, index) {
// compute hasNext slide
slideData.hasNext = index < slidesDataList.length-1;
// compute embed url
if (slideData.type === 'video') {
if (slideData.category === 'video' && slideData.videoSourceType !== 'vimeo') {
slideData.embedCode = $(slideData.embedCode).attr('src') || ""; // embedCode contains an iframe tag, where src attribute is the url (youtube or embed document from odoo)
var separator = slideData.embedCode.indexOf("?") !== -1 ? "&" : "?";
var scheme = slideData.embedCode.indexOf('//') === 0 ? 'https:' : '';
@@ -510,16 +603,26 @@
params.autoplay = 1;
}
slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : "";
} else if (slideData.type === 'infographic') {
} else if (slideData.category === 'video' && slideData.videoSourceType === 'vimeo') {
slideData.embedCode = Markup(slideData.embedCode);
} else if (slideData.category === 'infographic') {
slideData.embedUrl = _.str.sprintf('/web/image/slide.slide/%s/image_1024', slideData.id);
} else if (_.contains(['document', 'presentation'], slideData.type)) {
} else if (slideData.category === 'document') {
slideData.embedUrl = $(slideData.embedCode).attr('src');
}
// fill empty property to allow searching on it with _.filter(list, matcher)
slideData.isQuiz = !!slideData.isQuiz;
slideData.hasQuestion = !!slideData.hasQuestion;
// technical settings for the Fullscreen to work
slideData._autoSetDone = _.contains(['infographic', 'presentation', 'document', 'webpage'], slideData.type) && !slideData.hasQuestion;
var autoSetDone = false;
if (!slideData.hasQuestion) {
if (_.contains(['infographic', 'document', 'article'], slideData.category)) {
autoSetDone = true; // images, documents (local + external) and articles are marked as completed when opened
} else if (slideData.category === 'video' && slideData.videoSourceType === 'google_drive') {
autoSetDone = true; // google drive videos do not benefit from the YouTube integration and are marked as completed when opened
}
}
slideData._autoSetDone = autoSetDone;
});
return slidesDataList;
},
@@ -542,8 +645,8 @@
history.pushState(null, '', fullscreenUrl);
},
/**
* Render the current slide content using specific mecanism according to slide type:
* - simply append content (for webpage)
* Render the current slide content using specific mecanism according to slide category:
* - simply append content (for article)
* - template rendering (for image, document, ....)
* - using a sub widget (quiz and video)
*
@@ -556,20 +659,25 @@
$content.empty();
// display quiz slide, or quiz attached to a slide
if (slide.type === 'quiz' || slide.isQuiz) {
if (slide.category === 'quiz' || slide.isQuiz) {
$content.addClass('bg-white');
var QuizWidget = new Quiz(this, slide, this.channel);
return QuizWidget.appendTo($content);
}
// render slide content
if (_.contains(['document', 'presentation', 'infographic'], slide.type)) {
if (_.contains(['document', 'infographic'], slide.category)) {
$content.html(QWeb.render('website.slides.fullscreen.content', {widget: this}));
} else if (slide.type === 'video') {
this.videoPlayer = new VideoPlayer(this, slide);
} else if (slide.category === 'video' && slide.videoSourceType === 'youtube') {
this.videoPlayer = new VideoPlayerYouTube(this, slide);
return this.videoPlayer.appendTo($content);
} else if (slide.type === 'webpage'){
var $wpContainer = $('<div>').addClass('o_wslide_fs_webpage_content bg-white block w-100 overflow-auto');
} else if (slide.category === 'video' && slide.videoSourceType === 'vimeo') {
this.videoPlayer = new VideoPlayerVimeo(this, slide);
return this.videoPlayer.appendTo($content);
} else if (slide.category === 'video' && slide.videoSourceType === 'google_drive') {
$content.html(QWeb.render('website.slides.fullscreen.video.google_drive', {widget: this}));
} else if (slide.category === 'article'){
var $wpContainer = $('<div>').addClass('o_wslide_fs_article_content bg-white block w-100 overflow-auto');
$(slide.htmlContent).appendTo($wpContainer);
$content.append($wpContainer);
this.trigger_up('widgets_start_request', {
@@ -608,7 +716,7 @@
* Triggered whenever the user changes slides.
* When the current slide is changed, widget will be automatically updated
* and allowed to: fetch the content if needed, render it, update the url,
* and set slide as "completed" according to its type requirements. In
* and set slide as "completed" according to its category requirements. In
* mobile case (i.e. limited screensize), sidebar will be toggled since
* sidebar will block most or all of new slide visibility.
*
@@ -627,7 +735,7 @@
return self._renderSlide();
}).then(function() {
if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call
if (['document', 'presentation'].includes(slide.type)) {
if (slide.category === 'document') {
// only set the slide as completed after iFrame is loaded to avoid concurrent execution with 'embedUrl' controller
self.el.querySelector('iframe.o_wslides_iframe_viewer').addEventListener('load', () => self._setCompleted(slide.id));
} else {
@@ -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 :
@@ -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)
}]
});
@@ -10,9 +10,12 @@ var SlideUploadDialog = Dialog.extend({
template: 'website.slide.upload.modal',
events: _.extend({}, Dialog.prototype.events, {
'click .o_wslides_js_upload_install_button': '_onClickInstallModule',
'click .o_wslides_select_type': '_onClickSlideTypeIcon',
'click .o_wslides_select_category': '_onClickSlideCategoryIcon',
'change input#upload': '_onChangeSlideUpload',
'change input#url': '_onChangeSlideUrl',
'change input#video_url': '_onChangeVideoUrl',
'change input#image_google_url': '_onChangeImageUrl',
'change input#document_google_url': '_onChangeDocumentUrl',
'change input[name="source_type"]': '_onChangeSourceType',
}),
/**
@@ -24,7 +27,7 @@ var SlideUploadDialog = Dialog.extend({
*/
init: function (parent, options) {
options = _.defaults(options || {}, {
title: _t("Upload a document"),
title: _t("Add Content"),
size: 'medium',
});
this._super(parent, options);
@@ -60,10 +63,10 @@ var SlideUploadDialog = Dialog.extend({
* @private
* @param {string} message
*/
_alertDisplay: function (message) {
_alertDisplay: function (message, alertClass='alert-warning') {
this._alertRemove();
$('<div/>', {
"class": 'alert alert-warning',
"class": 'alert ' + alertClass,
id: 'upload-alert',
role: 'alert'
}).text(message).insertBefore(this.$('form'));
@@ -99,17 +102,21 @@ var SlideUploadDialog = Dialog.extend({
});
}));
},
_fetchUrlPreview: function (url) {
_fetchUrlPreview: function (url, slideCategory) {
return this._rpc({
route: '/slides/prepare_preview/',
params: {
'url': url,
'slide_category': slideCategory,
'channel_id': this.channelID
},
});
},
_formSetFieldValue: function (fieldId, value) {
this.$('form').find('#'+fieldId).val(value);
var $formField = this.$('form').find('#'+fieldId);
if (!$formField.val()) { // update only if the user did not assign a value manually
$formField.val(value);
}
},
_formGetFieldValue: function (fieldId) {
return this.$('#'+fieldId).val();
@@ -120,52 +127,53 @@ var SlideUploadDialog = Dialog.extend({
return form[0].checkValidity() && this.isValidUrl;
},
/**
* Extract values to submit from form, force the slide_type according to
* Extract values to submit from form, force the slide_category according to
* filled values.
*
* @private
*/
_formValidateGetValues: async function (forcePublished) {
var canvas = this.$('#data_canvas')[0];
var values = _.extend({
'channel_id': this.channelID,
'name': this._formGetFieldValue('name'),
'url': this._formGetFieldValue('url'),
'description': this._formGetFieldValue('description'),
'duration': this._formGetFieldValue('duration'),
'is_published': forcePublished,
}, this._getSelect2DropdownValues()); // add tags and category
// default slide_type (for webpage for instance)
if (_.contains(this.slide_type_data), this.get('state')) {
values['slide_type'] = this.get('state');
var slideCategory = 'document';
// default slide_category (for article for instance)
if (_.contains(this.slide_category_data), this.get('state')) {
slideCategory = this.get('state');
}
var sourceType = 'local_file';
if (slideCategory === 'video') {
sourceType = 'external'; // force external for videos
} else {
sourceType = this.$('input[name="source_type"]:checked').data('value');
}
var values = _.extend({
'channel_id': this.channelID,
'description': this._formGetFieldValue('description'),
'document_google_url': this._formGetFieldValue('document_google_url'),
'duration': this._formGetFieldValue('duration'),
'image_google_url': this._formGetFieldValue('image_google_url'),
'is_published': forcePublished,
'name': this._formGetFieldValue('name'),
'slide_category': slideCategory,
'source_type': sourceType,
'video_url': this._formGetFieldValue('video_url'),
}, this._getSelect2DropdownValues()); // add tags and category
var canvas = this.$('#data_canvas')[0];
if (this.file.type === 'application/pdf') {
_.extend(values, {
'image_1920': canvas.toDataURL().split(',')[1],
'slide_type': canvas.height > canvas.width ? 'document' : 'presentation',
'mime_type': this.file.type,
'datas': this.file.data
'slide_category': 'document',
'binary_content': this.file.data
});
} else if (values['slide_type'] === 'webpage') {
} else if (values['slide_category'] === 'article') {
_.extend(values, {
'mime_type': 'text/html',
'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data,
'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPng() : this.file.data,
});
} else if (/^image\/.*/.test(this.file.type)) {
const fileData = this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data;
if (values['slide_type'] === 'presentation') {
_.extend(values, {
'slide_type': 'infographic',
'mime_type': this.file.type === 'image/svg+xml' ? 'image/png' : this.file.type,
'datas': fileData,
});
} else {
_.extend(values, {
'image_1920': fileData,
});
}
_.extend(values, {
'slide_category': 'infographic',
'binary_content': this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data,
});
}
return values;
},
@@ -187,7 +195,7 @@ var SlideUploadDialog = Dialog.extend({
if (! this.modulesToInstallStatus.installing) {
btnList.push({text: this.modulesToInstallStatus.failed ? _t("Retry") : _t("Install"), classes: 'btn-primary', click: this._onClickInstallModuleConfirm.bind(this)});
}
btnList.push({text: _t("Discard"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)});
btnList.push({text: _t("Back"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)});
} else if (state !== '_upload') { // no button when uploading
if (this.canUpload) {
if (this.canPublish) {
@@ -197,7 +205,7 @@ var SlideUploadDialog = Dialog.extend({
btnList.push({text: _t("Save"), classes: 'btn-primary o_w_slide_upload', click: this._onClickFormSubmit.bind(this)});
}
}
btnList.push({text: _t("Discard"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)});
btnList.push({text: _t("Back"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)});
}
return btnList;
},
@@ -321,24 +329,29 @@ var SlideUploadDialog = Dialog.extend({
return values;
},
/**
* Init the data relative to the support slide type to upload
* Init the data relative to the support slide category to upload
*
* @private
*/
_setup: function () {
this.slide_type_data = {
presentation: {
this.slide_category_data = {
document: {
icon: 'fa-file-pdf-o',
label: _t('Presentation'),
template: 'website.slide.upload.modal.presentation',
label: _t('Document'),
template: 'website.slide.upload.modal.document',
},
webpage: {
infographic: {
icon: 'fa-file-image-o',
label: _t('Image'),
template: 'website.slide.upload.modal.infographic',
},
article: {
icon: 'fa-file-text',
label: _t('Web Page'),
template: 'website.slide.upload.modal.webpage',
label: _t('Article'),
template: 'website.slide.upload.modal.article',
},
video: {
icon: 'fa-video-camera',
icon: 'fa-file-video-o',
label: _t('Video'),
template: 'website.slide.upload.modal.video',
},
@@ -378,6 +391,80 @@ var SlideUploadDialog = Dialog.extend({
return png.split(',')[1];
},
/**
* When the URL changes for slides of categories infographic, document and video, we attempt to fetch
* some metadata on YouTube / Google Drive (such as a name, a title, a duration, ...).
*
* @param {string} url
* @param {string} slideCategory
*/
_updateSlideMetadata: function (url, slideCategory) {
var self = this;
this._alertRemove();
this.isValidUrl = false;
this.set('can_submit_form', false);
this._setModalLoading(true);
this._fetchUrlPreview(url, slideCategory).then(function (data) {
self.set('can_submit_form', true);
if (data.error) {
self._alertDisplay(data.error);
self._hidePreviewColumn();
} else {
if (data.info) {
self._alertDisplay(data.info, 'alert-info');
} else {
self._alertRemove();
}
self.isValidUrl = true;
if (data.name) {
self._formSetFieldValue('name', data.name);
self.$('#slide-video-title')
.text(data.name)
.removeClass('d-none');
} else {
self.$('#slide-video-title').addClass('d-none');
}
if (data.description) {
self._formSetFieldValue('description', data.description);
}
if (data.completion_time) {
// hours to minutes conversion
self._formSetFieldValue('duration', Math.round(data.completion_time * 60));
}
if (data.image_url) {
self.$('#slide-image').attr('src', data.image_url);
}
if (!data.name && !data.description && !data.image_url) {
self._hidePreviewColumn();
} else {
self._showPreviewColumn();
}
}
self._setModalLoading(false);
});
},
/**
* Typically used when loading slide metadata.
* Since the request result will change form values, it's better to wait for the return of the
* request to avoid having the user type text that will be overridden (such as the slide name
* for example).
*
* @param {boolean} loading true to mask the modal with a loading screen, false to remove it
*/
_setModalLoading: function (loading) {
if (loading) {
this.$el.closest('.modal-content').append(QWeb.render('website.slide.upload.modal.loading'));
} else {
this.$el.closest('.modal-content').find('.o_wslides_slide_upload_loading').remove();
}
},
//--------------------------------------------------------------------------
// Handler
//--------------------------------------------------------------------------
@@ -393,7 +480,7 @@ var SlideUploadDialog = Dialog.extend({
} else if (currentType === '_import') {
tmpl = 'website.slide.upload.modal.import';
} else {
tmpl = this.slide_type_data[currentType]['template'];
tmpl = this.slide_category_data[currentType]['template'];
this.$modal.find('.modal-dialog').addClass('modal-lg');
}
this.$('.o_w_slide_upload_modal_container').empty();
@@ -404,7 +491,7 @@ var SlideUploadDialog = Dialog.extend({
if (currentType === '_import') {
this.set_title(_t("New Certification"));
} else {
this.set_title(_t("Upload a document"));
this.set_title(_t("Add Content"));
}
},
_onChangeCanSubmitForm: function (ev) {
@@ -414,6 +501,28 @@ var SlideUploadDialog = Dialog.extend({
this.$('.o_w_slide_upload').button('loading');
}
},
/**
* When the user selects 'local_file' or 'external' as source type, we display the 'upload'
* field or the 'document_google_url' / 'image_google_url' fields respectively.
* We also toggle the 'required' attribute the same way.
*
* @param {Event} ev the onchange event
*/
_onChangeSourceType: function (ev) {
if (this.$('#source_type_local_file').is(':checked')) {
this.$('.o_wslides_js_slide_upload_local_file').removeClass('d-none');
this.$('.o_wslides_js_slide_upload_external').addClass('d-none');
this.$('#upload').attr('required', 'required');
this.$('#document_google_url, #image_google_url').removeAttr('required');
} else if (this.$('#source_type_external').is(':checked')) {
this.$('.o_wslides_js_slide_upload_external').removeClass('d-none');
this.$('.o_wslides_js_slide_upload_local_file').addClass('d-none');
this.$('#document_google_url, #image_google_url').attr('required', 'required');
this.$('#upload').removeAttr('required');
}
},
_onChangeSlideUpload: function (ev) {
var self = this;
this._alertRemove();
@@ -519,31 +628,15 @@ var SlideUploadDialog = Dialog.extend({
}
}
},
_onChangeSlideUrl: function (ev) {
var self = this;
var url = $(ev.target).val();
this._alertRemove();
this.isValidUrl = false;
this.set('can_submit_form', false);
this._fetchUrlPreview(url).then(function (data) {
self.set('can_submit_form', true);
if (data.error) {
self._alertDisplay(data.error);
} else {
if (data.completion_time) {
// hours to minutes conversion
self._formSetFieldValue('duration', Math.round(data.completion_time * 60));
}
self.$('#slide-image').attr('src', data.url_src);
self._formSetFieldValue('name', data.title);
self._formSetFieldValue('description', data.description);
self.isValidUrl = true;
self._showPreviewColumn();
}
});
_onChangeDocumentUrl: function (ev) {
this._updateSlideMetadata($(ev.target).val(), 'document');
},
_onChangeImageUrl: function (ev) {
this._updateSlideMetadata($(ev.target).val(), 'infographic');
},
_onChangeVideoUrl: function (ev) {
this._updateSlideMetadata($(ev.target).val(), 'video');
},
_onClickInstallModule: function (ev) {
var $btn = $(ev.currentTarget);
var moduleId = $btn.data('moduleId');
@@ -623,10 +716,10 @@ var SlideUploadDialog = Dialog.extend({
}
},
_onClickSlideTypeIcon: function (ev) {
_onClickSlideCategoryIcon: function (ev) {
var $elem = this.$(ev.currentTarget);
var slideType = $elem.data('slideType');
this.set('state', slideType);
var slideCategory = $elem.data('slideCategory');
this.set('state', slideCategory);
this._bindSelect2Dropdown(); // rebind select2 at each modal body rendering
},
@@ -42,11 +42,11 @@ tour.register('slides_tour', {
width: 260,
}, {
trigger: 'a.btn-primary.o_wslides_js_slide_upload',
content: Markup(_t("Your first section is created, now it's time to add lessons to your course. Click on <b>Add Content</b> 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 <b>Add Content</b> to upload a document, create an article or link a video.")),
position: 'bottom',
}, {
trigger: 'a[data-slide-type="presentation"]',
content: Markup(_t("First, let's add a <b>Presentation</b>. It can be a .pdf or an image.")),
trigger: 'a[data-slide-category="document"]',
content: Markup(_t("First, let's add a <b>Document</b>. It has to be a .pdf file.")),
position: 'bottom',
}, {
trigger: 'input#upload',
@@ -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 {
@@ -29,7 +29,7 @@ var addVideoToSection = function (sectionName, saveAsDraft) {
trigger: 'div.o_wslides_slide_list_category_header:contains("' + sectionName + '") a:contains("Add Content")',
}, {
content: 'eLearning: click on video',
trigger: 'a[data-slide-type=video]',
trigger: 'a[data-slide-category=video]',
}, {
content: 'eLearning: fill video link',
trigger: 'input[name=url]',
@@ -56,16 +56,16 @@ var addVideoToSection = function (sectionName, saveAsDraft) {
return base_steps;
};
var addWebPageToSection = function (sectionName, pageName) {
var addArticleToSection = function (sectionName, pageName) {
return [
{
content: 'eLearning: add content to section',
trigger: 'div.o_wslides_slide_list_category_header:contains("' + sectionName + '") a:contains("Add Content")',
}, {
content: 'eLearning: click on webpage',
trigger: 'a[data-slide-type=webpage]',
content: 'eLearning: click on article',
trigger: 'a[data-slide-category=article]',
}, {
content: 'eLearning: fill webpage title',
content: 'eLearning: fill article title',
trigger: 'input[name=name]',
run: 'text ' + pageName,
}, {
@@ -76,7 +76,7 @@ var addWebPageToSection = function (sectionName, pageName) {
trigger: 'div.select2-result-label:contains("Theory")',
in_modal: false,
}, {
content: 'eLearning: fill webpage completion time',
content: 'eLearning: fill article completion time',
trigger: 'input[name=duration]',
run: 'text 4',
}];
@@ -132,7 +132,7 @@ var addNewCourseTag = function (courseTagName) {
export default {
addSection: addSection,
addVideoToSection: addVideoToSection,
addWebPageToSection: addWebPageToSection,
addArticleToSection: addArticleToSection,
addExistingCourseTag: addExistingCourseTag,
addNewCourseTag: addNewCourseTag,
};
@@ -1,22 +1,34 @@
<templates id="template" xml:space="preserve">
<t t-name="website.slides.fullscreen.content">
<t t-if="_.contains(['document', 'presentation'], widget.get('slide').type)">
<t t-if="widget.get('slide').category === 'document'">
<div class="embed-responsive h-100">
<iframe t-att-src="widget.get('slide').embedUrl" class="o_wslides_iframe_viewer" allowFullScreen="true" frameborder="0"/>
</div>
</t>
<t t-if="widget.get('slide').type === 'infographic'">
<t t-if="widget.get('slide').category === 'infographic'">
<div class="o_wslides_fs_player w-100 h-100 overflow-auto d-flex align-items-start justify-content-center">
<img t-att-src="'/web/image/slide.slide/'+ widget.get('slide').id +'/image_1024'" class="img-fluid position-relative m-auto" alt="Slide image"/>
</div>
</t>
</t>
<t t-name="website.slides.fullscreen.video">
<t t-name="website.slides.fullscreen.video.google_drive">
<div class="player embed-responsive embed-responsive-16by9 embed-responsive-item h-100">
<iframe t-att-src="widget.get('slide').embedUrl" allowFullScreen="true" frameborder="0" autoplay="1" allow="autoplay"></iframe>
</div>
</t>
<t t-name="website.slides.fullscreen.video.youtube">
<div class="player embed-responsive embed-responsive-16by9 embed-responsive-item h-100">
<iframe t-att-id="'youtube-player' + widget.slide.id" t-att-src="widget.slide.embedUrl" allowFullScreen="true" frameborder="0" enablejsapi="1" autoplay="1" allow="autoplay"></iframe>
</div>
</t>
<t t-name="website.slides.fullscreen.video.vimeo">
<div class="player embed-responsive embed-responsive-16by9 embed-responsive-item h-100">
<t t-out="widget.slide.embedCode"/>
</div>
</t>
</templates>
@@ -52,7 +52,7 @@
</form>
</div>
<div t-if="widget.session.is_website_user" class="alert alert-info d-inline-block">
<p class="mb-0">Please <a t-attf-href="/web?redirect=#{window.location.href}" class="font-weight-bold"> login </a> to share this <t t-esc="widget.slide.type"/> by email.</p>
<p class="mb-0">Please <a t-attf-href="/web?redirect=#{window.location.href}" class="font-weight-bold"> login </a> to share this <t t-esc="widget.slide.category"/> by email.</p>
</div>
</t>
@@ -10,17 +10,17 @@
</t>
<!--
Slide Type Selection template
Slide Category Selection template
-->
<t t-name="website.slide.upload.modal.select">
<div class="row p-1 mt-4 mb-2">
<div t-foreach="widget.slide_type_data" t-as="slide_type" class="col-6 col-md-3">
<t t-set="type_data" t-value="widget.slide_type_data[slide_type]"/>
<div t-foreach="widget.slide_category_data" t-as="slide_category" class="col-6 col-md-4">
<t t-set="category_data" t-value="widget.slide_category_data[slide_category]"/>
<a href="#" t-att-data-slide-type="slide_type"
class="content-type d-flex flex-column align-items-center mb-4 o_wslides_select_type btn rounded border text-600 p-3">
<i t-attf-class="fa #{type_data['icon']} mb-2 fa-3x"/>
<t t-esc="type_data['label']"/>
<a href="#" t-att-data-slide-category="slide_category"
class="content-type d-flex flex-column align-items-center mb-4 o_wslides_select_category btn rounded border text-600 p-3">
<i t-attf-class="fa #{category_data['icon']} mb-2 fa-3x"/>
<t t-esc="category_data['label']"/>
</a>
</div>
</div>
@@ -55,7 +55,7 @@
</t>
<!--
Slide Type common form part template
Slide Category common form part template
-->
<t t-name="website.slide.upload.modal.common">
<div class="form-group">
@@ -71,9 +71,9 @@
<input id="tag_ids" name="tag_ids" type="hidden"/>
</div>
<div class="form-group">
<label for="duration" class="col-form-label">Duration</label>
<label for="duration" class="col-form-label">Estimated Completion Time</label>
<div class="input-group">
<input type="number" id="duration" min="0" name="duration" placeholder="Estimated slide completion time" class="form-control" autocomplete="off"/>
<input type="number" id="duration" min="0" name="duration" placeholder='e.g. "15"' class="form-control" autocomplete="off"/>
<div class="input-group-prepend">
<span class="input-group-text">Minutes</span>
</div>
@@ -82,16 +82,36 @@
</t>
<!--
Slide Type templates
Slide Category templates
-->
<t t-name="website.slide.upload.modal.presentation">
<t t-name="website.slide.upload.modal.document">
<div>
<form class="clearfix">
<div class="row">
<div id="o_wslides_js_slide_upload_left_column" class="col-md-6">
<div class="form-group">
<label for="upload" class="col-form-label">Choose a PDF or an Image</label>
<input id="upload" name="file" class="form-control h-100" accept="image/*,application/pdf" type="file" required="required"/>
<label for="source_type" class="col-form-label">Document Source</label><br/>
<div class="form-check ml-2">
<input class="form-check-input" type="radio" name="source_type" id="source_type_local_file" data-value="local_file" checked="checked"/>
<label class="form-check-label font-weight-normal" for="source_type_local_file">
Upload from Device
</label>
</div>
<div class="form-check ml-2">
<input class="form-check-input" type="radio" name="source_type" id="source_type_external" data-value="external"/>
<label class="form-check-label font-weight-normal" for="source_type_external">
Retrieve from Google Drive
</label>
</div>
</div>
<div class="form-group o_wslides_js_slide_upload_local_file">
<label for="upload" class="col-form-label">Choose a PDF</label>
<input id="upload" name="file" class="form-control h-100" accept="application/pdf" type="file" required="required"/>
</div>
<div class="form-group o_wslides_js_slide_upload_external d-none">
<label for="document_google_url" class="col-form-label">Document Link</label>
<input id="document_google_url" name="document_google_url" class="form-control h-100" autocomplete="off"
placeholder='e.g "https://drive.google.com/file/..."'/>
</div>
<canvas id="data_canvas" class="d-none"></canvas>
<t t-call="website.slide.upload.modal.common"/>
@@ -99,15 +119,39 @@
<div id="o_wslides_js_slide_upload_preview_column" class="col-md-6">
<div class="img-thumbnail h-100">
<div class="o_slide_tutorial p-3">
<div class="h5">How to upload your PowerPoint Presentations or Word Documents?</div>
<div class="mx-3 my-4">Save your presentations or documents as PDF files and upload them.</div>
<div class="alert alert-warning" role="alert">
<i class="fa fa-info-circle pr-2"/>
Only JPG, PNG, PDF, files types are supported
<div class="h5">How do I add new content?</div>
<div>
<span>You can either upload a file from your computer or insert a Google Drive link.</span><br/>
</div>
<div class="o_wslides_js_slide_upload_local_file">
<div class="h5 mt-3">What types of documents do we support?</div>
<div>
<span>When using local files, we only support PDF files.</span><br/>
<span>If you want to use other types of files, you may want to use an external source (Google Drive) instead.</span>
</div>
<div class="h5 mt-3">How to upload your PowerPoint Presentations or Word Documents?</div>
<div>
<span>Save your presentations or documents as PDF files and upload them.</span>
</div>
</div>
<div class="o_wslides_js_slide_upload_external d-none">
<div class="h5 mt-3">What types of documents do we support?</div>
<div>
Through Google Drive, we support most common types of documents.
Including regular documents (Google Doc, .docx), Sheets (Google Sheet, .xlsx), PowerPoints, ...
</div>
<div class="h5 mt-3">How to use Google Drive?</div>
<div>
<span>First, upload the file on your Google Drive account.</span><br/>
<span>Then, go into the file permissions and set it as "Anyone with the link".</span><br/>
<span>The Google Drive link to use here can be obtained by clicking the "Share" button in the Google interface.</span><br/>
<span>It should look similar to
<span class="font-italic">https://drive.google.com/file/d/ABC/view?usp=sharing</span></span>
</div>
</div>
</div>
<div class="o_slide_preview d-none">
<img src="/website_slides/static/src/img/document.png" id="slide-image" title="Content Preview" alt="Content Preview" class="img-fluid"/>
<div class="o_slide_preview d-none h-100 flex-column justify-content-center">
<img referrerPolicy="no-referrer" src="/website_slides/static/src/img/document.png" id="slide-image" title="Content Preview" alt="Content Preview" class="img-fluid"/>
</div>
</div>
</div>
@@ -116,7 +160,60 @@
</div>
</t>
<t t-name="website.slide.upload.modal.webpage">
<t t-name="website.slide.upload.modal.infographic">
<div>
<form class="clearfix">
<div class="row">
<div id="o_wslides_js_slide_upload_left_column" class="col-md-6">
<div class="form-group">
<label for="source_type" class="col-form-label">Image Source</label><br/>
<div class="form-check ml-2">
<input class="form-check-input" type="radio" name="source_type" id="source_type_local_file" data-value="local_file" checked="checked"/>
<label class="form-check-label font-weight-normal" for="source_type_local_file">
Upload from Device
</label>
</div>
<div class="form-check ml-2">
<input class="form-check-input" type="radio" name="source_type" id="source_type_external" data-value="external"/>
<label class="form-check-label font-weight-normal" for="source_type_external">
Retrieve from Google Drive
</label>
</div>
</div>
<div class="form-group o_wslides_js_slide_upload_local_file">
<label for="upload" class="col-form-label">Choose an Image</label>
<input id="upload" name="file" class="form-control h-100" accept="image/*" type="file" required="required"/>
</div>
<div class="form-group o_wslides_js_slide_upload_external d-none">
<label for="image_google_url" class="col-form-label">Image Link</label>
<input id="image_google_url" name="image_google_url" class="form-control h-100" autocomplete="off"
placeholder='e.g "https://drive.google.com/file/..."'/>
</div>
<canvas id="data_canvas" class="d-none"></canvas>
<t t-call="website.slide.upload.modal.common"/>
</div>
<div id="o_wslides_js_slide_upload_preview_column" class="col-md-6">
<div class="img-thumbnail h-100">
<div class="o_slide_tutorial p-3">
<div class="h5">How do I add new content?</div>
<div>
<span>You can either upload a file from your computer or insert a Google Drive link.</span><br/>
<span>The Google Drive link can be obtained by using the 'share' button in the Google interface.</span><br/>
<span>It should look similar to
<span class="font-italic">https://drive.google.com/file/d/ABC/view?usp=sharing</span></span>
</div>
</div>
<div class="o_slide_preview d-none h-100 flex-column justify-content-center">
<img referrerPolicy="no-referrer" src="/website_slides/static/src/img/document.png" id="slide-image" title="Content Preview" alt="Content Preview" class="img-fluid"/>
</div>
</div>
</div>
</div>
</form>
</div>
</t>
<t t-name="website.slide.upload.modal.article">
<div>
<form class="clearfix">
<div class="row">
@@ -127,8 +224,8 @@
<div id="o_wslides_js_slide_upload_preview_column" class="col-md-6">
<div class="img-thumbnail h-100">
<div class="o_slide_tutorial p-3">
<div class="h5">How to create a Lesson as a Web Page?</div>
<div class="mx-3 my-4">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.</div>
<div class="h5">How to create a Lesson as an Article?</div>
<div>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.</div>
</div>
</div>
</div>
@@ -143,8 +240,9 @@
<div class="row">
<div id="o_wslides_js_slide_upload_left_column" class="col-md-6">
<div class="form-group">
<label for="url" class="col-form-label">Youtube Link</label>
<input id="url" name="url" class="form-control" placeholder="Youtube Video URL" required="required"/>
<label for="video_url" class="col-form-label">Video Link</label>
<input id="video_url" name="video_url" class="form-control" autocomplete="off"
placeholder='e.g "https://www.youtube.com/watch?v=ebBez6bcSEc"' required="required"/>
</div>
<canvas id="data_canvas" class="d-none"></canvas>
<t t-call="website.slide.upload.modal.common"/>
@@ -153,12 +251,35 @@
<div class="img-thumbnail h-100">
<div class="o_slide_tutorial p-3">
<div class="h5">How to upload your videos ?</div>
<div class="mx-3 my-4">First, upload your videos on YouTube and mark them as <strong>unlisted</strong>. This way, they will be secured.</div>
<div class="mx-3 my-4">What does <strong>unlisted</strong> 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.</div>
<div class="mx-3 my-4"><a href="https://support.google.com/youtube/answer/157177" target="_blank" >Change video privacy settings</a></div>
<div class="h6">On YouTube</div>
<div>First, upload your videos on YouTube and mark them as <strong>unlisted</strong>. This way, they will be secured.</div>
<div>What does <strong>unlisted</strong> 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.</div>
<div><a href="https://support.google.com/youtube/answer/157177" target="_blank" >Change video privacy settings</a></div>
<br/>
<div class="h6">On Vimeo</div>
<div>
<span>First, upload your videos on Vimeo and mark them as <strong>Private</strong>. This way, they will be secured.</span><br/>
<span>What does <strong>Private</strong> 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.</span><br/>
<span><a href="https://vimeo.zendesk.com/hc/en-us/articles/224819527-Changing-the-privacy-settings-of-your-videos" target="_blank" >Change video privacy settings</a></span><br/><br/>
<span>The video link to input here can be obtained by using the 'share' button in the Vimeo interface.</span><br/>
<span>It should look similar to</span>
<span class="font-italic">https://vimeo.com/558907333/30da9ff3d8</span>
<span>for 'Private' videos and similar to</span>
<span class="font-italic">https://vimeo.com/558907555</span>
<span>for public ones.</span>
</div>
<br/>
<div class="h6">On Google Drive</div>
<div>
<span>The Google Drive link can be obtained by using the 'share' button in the Google interface.</span><br/>
<span>It should look similar to
<span class="font-italic">https://drive.google.com/file/d/ABC/view?usp=sharing</span></span>
</div>
</div>
<div class="o_slide_preview d-none">
<img src="/website_slides/static/src/img/document.png" id="slide-image" title="Content Preview" alt="Content Preview" class="img-fluid"/>
<div class="o_slide_preview d-none h-100 flex-column justify-content-center">
<img referrerPolicy="no-referrer" src="/website_slides/static/src/img/document.png" id="slide-image" title="Content Preview" alt="Content Preview" class="img-fluid"/>
<div id="slide-video-title" class="d-none mt-1"/>
</div>
</div>
</div>
@@ -179,7 +300,7 @@
<div class="img-thumbnail h-100">
<div class="o_slide_tutorial p-3">
<div class="h5">Test your students with small Quizzes</div>
<div class="mx-3 my-4">With Quizzes you can keep your students focused and motivated by answering some questions and gaining some karma points</div>
<div>With Quizzes you can keep your students focused and motivated by answering some questions and gaining some karma points</div>
<img src="/website_slides/static/src/img/onboarding-quiz.png" title="Quiz Demo Data" class="img-fluid"/>
</div>
</div>
@@ -188,4 +309,14 @@
</form>
</div>
</t>
<!-- Misc -->
<t t-name="website.slide.upload.modal.loading">
<div class="o_wslides_slide_upload_loading position-absolute h-100 w-100 text-center d-flex flex-column justify-content-center">
<h2 class="text-white">
<span class="fa fa-spinner fa-pulse"></span>
<span>Loading content...</span>
</h2>
</div>
</t>
</templates>
+3 -3
View File
@@ -55,7 +55,7 @@ class SlidesCase(common.TransactionCase):
cls.slide = cls.env['slide.slide'].with_user(cls.user_officer).create({
'name': 'How To Cook Humans',
'channel_id': cls.channel.id,
'slide_type': 'presentation',
'slide_category': 'document',
'is_published': True,
'completion_time': 2.0,
'sequence': 1,
@@ -70,7 +70,7 @@ class SlidesCase(common.TransactionCase):
cls.slide_2 = cls.env['slide.slide'].with_user(cls.user_officer).create({
'name': 'How To Cook For Humans',
'channel_id': cls.channel.id,
'slide_type': 'presentation',
'slide_category': 'document',
'is_published': True,
'completion_time': 3.0,
'sequence': 3,
@@ -78,7 +78,7 @@ class SlidesCase(common.TransactionCase):
cls.slide_3 = cls.env['slide.slide'].with_user(cls.user_officer).create({
'name': 'How To Cook Humans For Humans',
'channel_id': cls.channel.id,
'slide_type': 'document',
'slide_category': 'document',
'is_published': True,
'completion_time': 1.5,
'sequence': 4,
+2 -2
View File
@@ -28,14 +28,14 @@ class TestKarmaGain(common.SlidesCase):
self.slide_2_0 = self.env['slide.slide'].with_user(self.user_officer).create({
'name': 'How to travel through space and time',
'channel_id': self.channel_2.id,
'slide_type': 'presentation',
'slide_category': 'document',
'is_published': True,
'completion_time': 2.0,
})
self.slide_2_1 = self.env['slide.slide'].with_user(self.user_officer).create({
'name': 'How to duplicate yourself',
'channel_id': self.channel_2.id,
'slide_type': 'presentation',
'slide_category': 'document',
'is_published': True,
'completion_time': 2.0,
})
@@ -168,8 +168,8 @@ class TestSequencing(slides_common.SlidesCase):
class TestFromURL(slides_common.SlidesCase):
def test_youtube_urls(self):
urls = {
def test_video_youtube(self):
youtube_urls = {
'W0JQcpGLSFw': [
'https://youtu.be/W0JQcpGLSFw',
'https://www.youtube.com/watch?v=W0JQcpGLSFw',
@@ -188,9 +188,75 @@ class TestFromURL(slides_common.SlidesCase):
],
}
for id, urls in urls.items():
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various YouTube URL formats
for youtube_id, urls in youtube_urls.items():
for url in urls:
with self.subTest(url=url, id=id):
document = self.env['slide.slide']._find_document_data_from_url(url)
self.assertEqual(document[0], 'youtube')
self.assertEqual(document[1], id)
with self.subTest(url=url, id=youtube_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('youtube', slide.video_source_type)
self.assertEqual(youtube_id, slide.youtube_id)
def test_video_google_drive(self):
google_drive_urls = {
'1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR': [
'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing',
'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various Google Drive URL formats
for google_drive_id, urls in google_drive_urls.items():
for url in urls:
with self.subTest(url=url, id=google_drive_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('google_drive', slide.video_source_type)
self.assertEqual(google_drive_id, slide.google_drive_id)
def test_video_vimeo(self):
vimeo_urls = {
# regular URL from Vimeo
'545859999': [
'https://vimeo.com/545859999',
'https://vimeo.com/545859999?autoplay=1',
],
# test channel URL from Vimeo
'551979139': [
'https://vimeo.com/channels/staffpicks/551979139',
'https://vimeo.com/channels/staffpicks/551979139?autoplay=1',
],
# test URL from Vimeo with setting 'with URL only'
# we need to store both the ID and the token, see '_compute_embed_code' method for details
'545859999/94dd03ddb0': [
'https://vimeo.com/545859999/94dd03ddb0',
'https://vimeo.com/545859999/94dd03ddb0?autoplay=1',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various Vimeo URL formats
for vimeo_id, urls in vimeo_urls.items():
for url in urls:
with self.subTest(url=url, id=vimeo_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('vimeo', slide.video_source_type)
self.assertEqual(vimeo_id, slide.vimeo_id)
+6 -10
View File
@@ -39,12 +39,11 @@ class TestChannelStatistics(common.SlidesCase):
@mute_logger('odoo.models')
def test_channel_statistics(self):
channel_publisher = self.channel.with_user(self.user_officer)
# slide type computation
# slide category computation
self.assertEqual(channel_publisher.total_slides, len(channel_publisher.slide_content_ids))
self.assertEqual(channel_publisher.nbr_infographic, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_type == 'infographic')))
self.assertEqual(channel_publisher.nbr_presentation, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_type == 'presentation')))
self.assertEqual(channel_publisher.nbr_document, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_type == 'document')))
self.assertEqual(channel_publisher.nbr_video, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_type == 'video')))
self.assertEqual(channel_publisher.nbr_infographic, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_category == 'infographic')))
self.assertEqual(channel_publisher.nbr_document, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_category == 'document')))
self.assertEqual(channel_publisher.nbr_video, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_category == 'video')))
# slide statistics computation
self.assertEqual(float_compare(channel_publisher.total_time, sum(s.completion_time for s in channel_publisher.slide_content_ids), 3), 0)
# members computation
@@ -156,14 +155,11 @@ class TestSlideStatistics(common.SlidesCase):
self.assertEqual(slide_emp.total_views, 5)
@users('user_officer')
def test_slide_statistics_types(self):
def test_slide_statistics_categories(self):
category = self.category.with_user(self.env.user)
self.assertEqual(
category.nbr_presentation,
len(category.channel_id.slide_ids.filtered(lambda s: s.category_id == category and s.slide_type == 'presentation')))
self.assertEqual(
category.nbr_document,
len(category.channel_id.slide_ids.filtered(lambda s: s.category_id == category and s.slide_type == 'document')))
len(category.channel_id.slide_ids.filtered(lambda s: s.category_id == category and s.slide_category == 'document')))
self.assertEqual(self.channel.total_slides, 3, 'The channel should contain 3 slides')
self.assertEqual(category.total_slides, 2, 'The first category should contain 2 slides')
@@ -33,32 +33,32 @@ class TestUICommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal):
(0, 0, {
'name': 'Gardening: The Know-How',
'sequence': 1,
'datas': pdf_content,
'slide_type': 'presentation',
'binary_content': pdf_content,
'slide_category': 'document',
'is_published': True,
'is_preview': True,
}), (0, 0, {
'name': 'Home Gardening',
'sequence': 2,
'image_1920': img_content,
'slide_type': 'infographic',
'slide_category': 'infographic',
'is_published': True,
}), (0, 0, {
'name': 'Mighty Carrots',
'sequence': 3,
'image_1920': img_content,
'slide_type': 'infographic',
'slide_category': 'infographic',
'is_published': True,
}), (0, 0, {
'name': 'How to Grow and Harvest The Best Strawberries | Basics',
'sequence': 4,
'datas': pdf_content,
'slide_type': 'document',
'binary_content': pdf_content,
'slide_category': 'document',
'is_published': True,
}), (0, 0, {
'name': 'Test your knowledge',
'sequence': 5,
'slide_type': 'quiz',
'slide_category': 'quiz',
'is_published': True,
'question_ids': [
(0, 0, {
@@ -77,9 +77,9 @@
<tree decoration-bf="is_category" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="slide_type" attrs="{'invisible': [('slide_type', '=', 'category')]}"/>
<field name="completion_time" attrs="{'invisible': [('slide_type', '=', 'category')]}" string="Duration" widget="float_time"/>
<field name="total_views" attrs="{'invisible': [('slide_type', '=', 'category')]}"/>
<field name="slide_category" attrs="{'invisible': [('slide_category', '=', 'category')]}"/>
<field name="completion_time" attrs="{'invisible': [('slide_category', '=', 'category')]}" string="Duration" widget="float_time"/>
<field name="total_views" attrs="{'invisible': [('slide_category', '=', 'category')]}"/>
<field name="is_preview" string="Preview"/>
<field name="is_published" string="Published"/>
<field name="is_category" invisible="1"/>
@@ -116,14 +116,40 @@
<group name="lesson_details">
<field name="active" invisible="1"/>
<field name="channel_id"/>
<field name="slide_type" string="Content Type"/>
<field name="url" attrs="{
'required': [('slide_type', 'in', ('video'))],
'invisible': [('slide_type', 'not in', ('video'))]}" />
<field name="document_id" invisible="1"/>
<field name="mime_type" force_save="1" readonly="1" groups="base.group_no_one"/>
<field name="datas" string="Attachment"
attrs="{'invisible': [('slide_type', 'not in', ('document', 'presentation'))]}"/>
<field name="slide_category" string="Content Type"/>
<field name="slide_type" invisible="1"/>
<div class="text-muted" colspan="2" attrs="{
'invisible': [('slide_category', '!=', 'quiz')]}">
You can add questions to this quiz in the 'Quiz' tab.
</div>
<label for="source_type" string="" attrs="{
'invisible': [('slide_category', 'not in', ['infographic', 'document'])]}"/>
<field name="source_type" widget="radio" nolabel="1" attrs="{
'invisible': [('slide_category', 'not in', ['infographic', 'document'])]}" />
<field name="video_url" attrs="{
'required': [('slide_category', '=', 'video')],
'invisible': [('slide_category', '!=', 'video')],
'readonly': [('slide_category', '!=', 'video')]}"
placeholder='e.g "www.youtube.com/watch?v=ebBez6bcSEc"'
widget="url"/>
<field name="document_google_url" attrs="{
'invisible': ['|', ('source_type', '!=', 'external'), ('slide_category', '!=', 'document')],
'readonly': ['|', ('source_type', '!=', 'external'), ('slide_category', '!=', 'document')]}"
placeholder='e.g "https://drive.google.com/file/..."'
widget="url"/>
<field name="image_google_url" attrs="{
'invisible': ['|', ('source_type', '!=', 'external'), ('slide_category', '!=', 'infographic')],
'readonly': ['|', ('source_type', '!=', 'external'), ('slide_category', '!=', 'infographic')]}"
placeholder='e.g "https://drive.google.com/file/..."'
widget="url"/>
<field name="document_binary_content" string="" options="{'accepted_file_extensions': '.pdf'}"
attrs="{
'invisible': ['|', ('source_type', '=', 'external'), ('slide_category', '!=', 'document')],
'readonly': ['|', ('source_type', '=', 'external'), ('slide_category', '!=', 'document')]}"/>
<field name="image_binary_content" string="" options="{'accepted_file_extensions': 'image/*'}"
attrs="{
'invisible': ['|', ('source_type', '=', 'external'), ('slide_category', '!=', 'infographic')],
'readonly': ['|', ('source_type', '=', 'external'), ('slide_category', '!=', 'infographic')]}"/>
</group>
<group name="related_details">
<field name="user_id" string="Responsible" widget="many2one_avatar"/>
@@ -133,7 +159,7 @@
<span> hours</span>
</div>
<field name="is_preview"/>
<field name="slide_resource_downloadable" attrs="{'invisible': [('slide_type', 'not in', ['presentation', 'document'])]}"/>
<field name="slide_resource_downloadable" attrs="{'invisible': ['|', ('slide_category', '!=', 'document'), ('source_type', '!=', 'local_file')]}"/>
<field name="date_published" string="Published Date" attrs="{'invisible': [('date_published', '=', False)]}" groups="base.group_no_one"/>
</group>
</group>
@@ -211,7 +237,7 @@
sample="1">
<field name="id"/>
<field name="channel_id"/>
<field name="slide_type"/>
<field name="slide_category"/>
<field name="user_id"/>
<field name="image_128"/>
<templates>
@@ -242,20 +268,20 @@
</div>
<div class="o_kanban_record_bottom mt-auto d-flex justify-content-between align-items-end">
<span>
<t t-if="record.slide_type.raw_value == 'infographic'">
<t t-if="record.slide_category.raw_value == 'infographic'">
<i class="fa fa-file-image-o mr-2" aria-label="Infographic" role="img" title="Infographic"/>
</t>
<t t-elif="record.slide_type.raw_value == 'webpage'">
<i class="fa fa-file-code-o mr-2" aria-label="Webpage" role="img" title="Webpage"/>
<t t-elif="record.slide_category.raw_value == 'article'">
<i class="fa fa-file-code-o mr-2" aria-label="article" role="img" title="Article"/>
</t>
<t t-elif="record.slide_type.raw_value == 'video'">
<t t-elif="record.slide_category.raw_value == 'video'">
<i class="fa fa-file-video-o mr-2" aria-label="Video" role="img" title="Video"/>
</t>
<t t-elif="record.slide_type.raw_value == 'quiz'">
<t t-elif="record.slide_category.raw_value == 'quiz'">
<i class="fa fa-flag mr-2" aria-label="Quiz" role="img" title="Quiz"/>
</t>
<t t-else=""><i class="fa fa-file-pdf-o mr-2" aria-label="Document" role="img" title="Document"/></t>
<field name="slide_type"/>
<field name="slide_category"/>
</span>
<span>
<i class="fa fa-clock-o mr-2" aria-label="Duration" role="img" title="Duration"/><field name="completion_time" widget="float_time"/>
@@ -324,7 +350,7 @@
<group expand="0" string="Group By">
<filter string="Course" name="groupby_channel" domain="[]" context="{'group_by': 'channel_id'}"/>
<filter string="Category" name="groupby_category" domain="[]" context="{'group_by': 'category_id'}"/>
<filter string="Type" name="groupby_type" domain="[]" context="{'group_by': 'slide_type'}"/>
<filter string="Type" name="groupby_type" domain="[]" context="{'group_by': 'slide_category'}"/>
</group>
</search>
</field>
@@ -336,7 +362,7 @@
<field name="arch" type="xml">
<graph string="Graph of Contents" stacked="0" sample="1">
<field name="channel_id"/>
<field name="slide_type"/>
<field name="slide_category"/>
<field name="total_views" type="measure"/>
<field name="quiz_first_attempt_reward" invisible="1"/>
<field name="quiz_second_attempt_reward" invisible="1"/>
@@ -13,7 +13,7 @@
<a href="/slides">Courses</a>
</li>
<t t-set="breadcrumb_class" t-value="'breadcrumb-item %s' % ('active' if not slide else '')" />
<li t-att-class="'breadcrumb-item %s' % ('active' if not search_category and not search_tag and not search_slide_type and not slide else '')">
<li t-att-class="'breadcrumb-item %s' % ('active' if not search_category and not search_tag and not search_slide_category and not slide else '')">
<a t-att-href="'/slides/%s' % slug(channel)"><span t-esc="channel.name"/></a>
</li>
<li t-att-class="breadcrumb_class" t-att-aria-current="'page' and search_category" t-if="search_category">
@@ -25,8 +25,8 @@
<li t-att-class="breadcrumb_class" t-att-aria-current="'page' and search_uncategorized" t-if="search_uncategorized">
<a t-att-href="'/slides/%s?search_uncategorized=1' % (slug(channel))">Uncategorized</a>
</li>
<li t-att-class="breadcrumb_class" t-att-aria-current="'page' and search_slide_type" t-if="search_slide_type">
<a t-att-href="'/slides/%s?slide_type=%s' % (slug(channel), search_slide_type)"><span t-esc="slide_types[search_slide_type]"/></a>
<li t-att-class="breadcrumb_class" t-att-aria-current="'page' and search_slide_category" t-if="search_slide_category">
<a t-att-href="'/slides/%s?slide_category=%s' % (slug(channel), search_slide_category)"><span t-esc="slide_categories[search_slide_category]"/></a>
</li>
<li t-if="slide" class="breadcrumb-item active">
<a t-att-href="'/slides/slide/%s' % slug(slide)"><span t-esc="slide.name"/></a>
@@ -58,7 +58,7 @@
<div class="dropdown-menu">
<a class="dropdown-item" href="/slides">Home</a>
<t t-set="dropdown_class" t-value="'dropdown-item %s' % ('active' if not slide else '')"/>
<a t-att-class="'dropdown-item %s' % ('active' if not search_category and not search_tag and not search_slide_type else '')" t-att-href="'/slides/%s' % slug(channel)">
<a t-att-class="'dropdown-item %s' % ('active' if not search_category and not search_tag and not search_slide_category else '')" t-att-href="'/slides/%s' % slug(channel)">
&#9492;<span class="ml-1" t-esc="channel.name"/>
</a>
<a t-att-class="dropdown_class" t-att-aria-current="'page' and search_category" t-if="search_category" t-att-href="'/slides/%s/category/%s' % (slug(channel), slug(search_category))">
@@ -70,8 +70,8 @@
<a t-att-class="dropdown_class" t-att-aria-current="'page' and search_uncategorized" t-if="search_uncategorized" t-att-href="'/slides/%s?search_uncategorized=1' % (slug(channel))">
&#9492;<span class="ml-1">Uncategorized</span>
</a>
<a t-att-class="dropdown_class" t-att-aria-current="'page' and search_slide_type" t-if="search_slide_type" t-att-href="'/slides/%s?slide_type=%s' % (slug(channel), search_slide_type)">
&#9492;<span class="ml-1" t-esc="slide_types[search_slide_type]"/>
<a t-att-class="dropdown_class" t-att-aria-current="'page' and search_slide_category" t-if="search_slide_category" t-att-href="'/slides/%s?slide_category=%s' % (slug(channel), search_slide_category)">
&#9492;<span class="ml-1" t-esc="slide_categories[search_slide_category]"/>
</a>
<a t-if="slide" class="dropdown-item active" t-att-href="'/slides/slide/%s' % (slug(slide))">
&#9492;<span class="ml-1" t-esc="slide.name"/>
@@ -367,7 +367,7 @@
<a t-if="channel.can_upload"
class="o_text_link o_wslides_js_slide_upload px-3 py-2"
role="button"
aria-label="Upload Presentation"
aria-label="Upload Document"
href="#"
t-att-data-modules-to-install="modules_to_install"
t-att-data-channel-id="channel.id"
@@ -393,7 +393,7 @@
<div t-if="channel.can_upload" class="o_wslides_content_actions btn-group">
<a class="o_wslides_js_slide_upload mr-1 border btn btn-primary"
role="button"
aria-label="Upload Presentation"
aria-label="Upload Document"
href="#"
t-att-data-open-modal="enable_slide_upload"
t-att-data-modules-to-install="modules_to_install"
@@ -504,7 +504,7 @@
<a name="o_wslides_list_slide_add_quizz" t-if="channel.can_upload and not slide.question_ids" t-attf-href="/slides/slide/#{slug(slide)}?quiz_quick_create">
<span class="badge badge-light badge-hide border font-weight-normal px-2 py-1 m-1">Add Quiz</span>
</a>
<a t-if="channel.can_upload" href="#">
<a t-if="channel.can_upload" href="#" name="o_wslides_slide_toggle_is_preview">
<span t-att-data-slide-id="slide.id" t-attf-class="o_wslides_js_slide_toggle_is_preview badge #{'badge-success' if slide.is_preview else 'badge-light badge-hide border'} font-weight-normal px-2 py-1 m-1"><span>Preview</span></span>
</a>
<t t-elif="slide.is_preview and not channel.is_member">
@@ -526,7 +526,7 @@
<i t-else="" class="check-done text-success fa fa-check-circle px-2"></i>
</t>
<span t-if="channel.can_publish" class="d-none d-md-flex">
<a t-if="slide.slide_type == 'webpage'" class="px-2 o_text_link text-primary" target="_blank" t-attf-href="/slides/slide/#{slug(slide)}?enable_editor=1"><span class="fa fa-pencil"/></a>
<a t-if="slide.slide_category == 'article'" class="px-2 o_text_link text-primary" target="_blank" t-attf-href="/slides/slide/#{slug(slide)}?enable_editor=1"><span class="fa fa-pencil"/></a>
<a t-else="" class="px-2 o_text_link text-primary" target="_blank" t-attf-href="/web#id=#{slide.id}&amp;action=#{slide_action}&amp;model=slide.slide&amp;view_type=form" title="Edit in backend"><span class="fa fa-pencil"/></a>
<a href="#" t-att-data-slide-id="slide.id" class="o_text_link text-danger px-2 o_wslides_js_slide_archive"><span class="fa fa-trash"/></a>
</span>
@@ -537,7 +537,7 @@
<!-- ======= Documentation Course content: cards / categories======= -->
<template id="course_promoted_slide" name="Documentation Course content: promoted slide">
<div class="o_wslides_promoted_slide">
<div t-if="not search and not search_slide_type and slide_promoted" class="container py-1 mb-2">
<div t-if="not search and not search_slide_category and slide_promoted" class="container py-1 mb-2">
<div class="card flex-column flex-lg-row">
<t t-set="image_url" t-value="website.image_url(slide_promoted, 'image_1024')"/>
@@ -574,7 +574,7 @@
<nav class="navbar navbar-expand-lg navbar-light bg-transparent col">
<a class="navbar-brand d-lg-none" href="#">Filter &amp; order</a>
<div class="form-inline ml-auto d-lg-none" t-if="search_slide_type or search">
<div class="form-inline ml-auto d-lg-none" t-if="search_slide_category or search">
<a t-att-href="'/slides/%s' % (slug(channel))" class="btn btn-info mr-3">
<i class="fa fa-eraser mr-1"/>Clear filters
</a>
@@ -588,23 +588,23 @@
<div class="col-12">
<ul class="navbar-nav mr-lg-auto align-items-lg-center">
<t t-set="slide_type_keys" t-value="slide_types.keys()"/>
<t t-foreach="slide_type_keys" t-as="slide_type_key">
<t t-set="slide_category_keys" t-value="slide_categories.keys()"/>
<t t-foreach="slide_category_keys" t-as="slide_category_key">
<t t-if="search_category">
<li t-if="search_category['nbr_%s' % slide_type_key] > 0" class="nav-item">
<a t-att-href="'/slides/%s/category/%s?%s' % (slug(channel), slug(search_category), keep_query(slide_type=slide_type_key))"
t-att-class="'nav-link d-flex align-items-center justify-content-between mr-1 %s' % ('active' if search_slide_type == slide_type_key else '')">
<t t-esc="slide_types[slide_type_key]"/>
<span t-attf-class="badge badge-pill ml-1 #{'badge-info' if search_slide_type == slide_type_key else 'bg-400'}" t-esc="search_category['nbr_%s' % slide_type_key]"/>
<li t-if="search_category['nbr_%s' % slide_category_key] > 0" class="nav-item">
<a t-att-href="'/slides/%s/category/%s?%s' % (slug(channel), slug(search_category), keep_query(slide_category=slide_category_key))"
t-att-class="'nav-link d-flex align-items-center justify-content-between mr-1 %s' % ('active' if search_slide_category == slide_category_key else '')">
<t t-esc="slide_categories[slide_category_key]"/>
<span t-attf-class="badge badge-pill ml-1 #{'badge-info' if search_slide_category == slide_category_key else 'bg-400'}" t-esc="search_category['nbr_%s' % slide_category_key]"/>
</a>
</li>
</t>
<t t-else="">
<li t-if="channel['nbr_%s' % slide_type_key] > 0" class="nav-item">
<a t-att-href="'/slides/%s?%s' % (slug(channel), keep_query(slide_type=slide_type_key))"
t-att-class="'nav-link d-flex align-items-center justify-content-between mr-1 %s' % ('active' if search_slide_type == slide_type_key else '')">
<t t-esc="slide_types[slide_type_key]"/>
<span t-attf-class="badge badge-pill ml-1 #{'badge-info' if search_slide_type == slide_type_key else 'bg-400'}" t-esc="channel['nbr_%s' % slide_type_key]"/>
<li t-if="channel['nbr_%s' % slide_category_key] > 0" class="nav-item">
<a t-att-href="'/slides/%s?%s' % (slug(channel), keep_query(slide_category=slide_category_key))"
t-att-class="'nav-link d-flex align-items-center justify-content-between mr-1 %s' % ('active' if search_slide_category == slide_category_key else '')">
<t t-esc="slide_categories[slide_category_key]"/>
<span t-attf-class="badge badge-pill ml-1 #{'badge-info' if search_slide_category == slide_category_key else 'bg-400'}" t-esc="channel['nbr_%s' % slide_category_key]"/>
</a>
</li>
</t>
@@ -626,18 +626,18 @@
</a>
<div class="dropdown-menu" aria-labelledby="slidesChannelDropdownSort" role="menu">
<h6 class="dropdown-header">Sort by</h6>
<a role="menuitem" t-att-href="'/slides/%s?%s' % (slug(channel), keep_query('slide_type', sorting='latest'))"
<a role="menuitem" t-att-href="'/slides/%s?%s' % (slug(channel), keep_query('slide_category', sorting='latest'))"
t-att-class="'dropdown-item %s' % ('active' if sorting and sorting == 'latest' else '')">Newest</a>
<a role="menuitem" t-att-href="'/slides/%s?%s' % (slug(channel), keep_query('slide_type', sorting='most_voted'))"
<a role="menuitem" t-att-href="'/slides/%s?%s' % (slug(channel), keep_query('slide_category', sorting='most_voted'))"
t-att-class="'dropdown-item %s' % ('active' if sorting and sorting == 'most_voted' else '')">Most Voted</a>
<a role="menuitem" t-att-href="'/slides/%s?%s' % (slug(channel), keep_query('slide_type', sorting='most_viewed'))"
<a role="menuitem" t-att-href="'/slides/%s?%s' % (slug(channel), keep_query('slide_category', sorting='most_viewed'))"
t-att-class="'dropdown-item %s' % ('active' if sorting and sorting == 'most_viewed' else '')">Most Viewed</a>
</div>
</li>
</ul>
<div class="form-inline mr-3 d-none d-lg-inline-block">
<a t-if="search_slide_type or search" t-att-href="'/slides/%s' % (slug(channel))" class="btn btn-sm btn-info ml-1">
<a t-if="search_slide_category or search" t-att-href="'/slides/%s' % (slug(channel))" class="btn btn-sm btn-info ml-1">
<i class="fa fa-eraser mr-1"/>Clear filters
</a>
</div>
@@ -680,12 +680,12 @@
<div t-if="channel.can_upload" class="text-right pb-2 col-auto">
<a class="btn btn-primary py-1 o_wslides_js_slide_upload"
title="Upload Presentation" role="button"
aria-label="Upload Presentation" href="#"
title="Upload Document" role="button"
aria-label="Upload Document" href="#"
t-att-data-channel-id="channel.id"
t-att-data-can-upload="channel.can_upload"
t-att-data-can-publish="channel.can_publish">
<i class="fa fa-cloud-upload mr-1"/>Upload new content
<i class="fa fa-cloud-upload mr-1"/>Add Content
</a>
<a class="btn btn-secondary py-1 o_wslides_js_slide_section_add"
title="Add Section" role="button"
@@ -796,7 +796,7 @@
</span>
</div>
<t t-if="channel.is_member and channel_progress[slide.id].get('completed')">
<span class="badge badge-pill badge-success"><i class="fa fa-check"/> Completed</span>
<span class="badge badge-pill badge-success"><i class="fa fa-check"/> Done</span>
</t>
</div>
</div>
@@ -805,13 +805,17 @@
<template id="slide_icon">
<t t-set="icon_class" t-value="icon_class if icon_class else 'mr-2 text-muted'"/>
<i t-if="slide.slide_type == 'document'" t-att-class="'fa fa-file-pdf-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'presentation'" t-att-class="'fa fa-file-pdf-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'infographic'" t-att-class="'fa fa-file-picture-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'video'" t-att-class="'fa fa-play-circle %s' % icon_class"></i>
<i t-if="slide.slide_type == 'link'" t-att-class="'fa fa-file-code-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'webpage'" t-att-class="'fa fa-file-text %s' % icon_class"></i>
<i t-if="slide.slide_type == 'pdf'" t-att-class="'fa fa-file-pdf-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'sheet'" t-att-class="'fa fa-file-excel-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'doc'" t-att-class="'fa fa-file-word-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'slides'" t-att-class="'fa fa-file-powerpoint-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'image'" t-att-class="'fa fa-file-picture-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'youtube_video'" t-att-class="'fa fa-youtube-play %s' % icon_class"></i>
<i t-if="slide.slide_type == 'google_drive_video'" t-att-class="'fa fa-play-circle %s' % icon_class"></i>
<i t-if="slide.slide_type == 'vimeo_video'" t-att-class="'fa fa-vimeo %s' % icon_class"></i>
<i t-if="slide.slide_type == 'article'" t-att-class="'fa fa-file-text %s' % icon_class"></i>
<i t-if="slide.slide_type == 'quiz'" t-att-class="'fa fa-question-circle %s' % icon_class"></i>
<i t-if="not slide.slide_type" t-att-class="'fa fa-file-o %s' % icon_class"></i>
</template>
</data></odoo>
@@ -173,7 +173,7 @@
<section class="s_banner bg-900" style="background-image: url(&quot;/website_slides/static/src/img/banner_default_all.svg&quot;); background-size: cover; background-position: 80% 20%" data-snippet="s_banner">
<div class="container py-5">
<h1 t-if="search_my" class="display-3 mb-0">My Courses</h1>
<h1 t-elif="search_slide_type=='certification'" class="display-3 mb-0">Certifications</h1>
<h1 t-elif="search_slide_category=='certification'" class="display-3 mb-0">Certifications</h1>
<h1 t-else="" class="display-3 mb-0">All Courses</h1>
</div>
</section>
@@ -184,7 +184,7 @@
<a class="nav-link nav-item px-3" href="/slides"><i class="fa fa-chevron-left"/></a>
</div>
<!-- Clear filtering (mobile)-->
<div class="form-inline text-nowrap ml-auto d-md-none" t-if="search_slide_type or search_my or search_tags">
<div class="form-inline text-nowrap ml-auto d-md-none" t-if="search_slide_category or search_my or search_tags">
<a href="/slides/all" class="btn btn-info mr-2" role="button" title="Clear filters">
<i class="fa fa-eraser"/> Clear filters
</a>
@@ -199,7 +199,7 @@
<t t-set="placeholder">Search courses</t>
<t t-set="search" t-value="original_search or search_term"/>
<input t-if="search_my" type="hidden" name="my" t-att-value="1"/>
<input t-if="search_slide_type" type="hidden" name="slide_type" t-att-value="search_slide_type" />
<input t-if="search_slide_category" type="hidden" name="slide_category" t-att-value="search_slide_category" />
</t>
<button class="navbar-toggler px-1" type="button"
data-toggle="collapse" data-target="#navbarTagGroups"
@@ -221,7 +221,7 @@
<div class="dropdown-menu" t-att-id="'navToogleTagGroup%s' % tag_group.id">
<t t-foreach="group_frontend_tags" t-as="tag">
<a rel="nofollow" t-att-class="'dropdown-item post_link %s' % ('active' if tag in search_tags else '')"
t-att-href="slide_query_url(tag=slugify_tags(search_tags.ids, toggle_tag_id=tag.id), my=search_my, search=search_term, slide_type=search_slide_type)"
t-att-href="slide_query_url(tag=slugify_tags(search_tags.ids, toggle_tag_id=tag.id), my=search_my, search=search_term, slide_category=search_slide_category)"
t-esc="tag.name"/>
</t>
</div>
@@ -229,7 +229,7 @@
</t>
</ul>
<!-- Clear filtering (desktop)-->
<div class="form-inline ml-auto d-none d-md-flex" t-if="search_slide_type or search_my or search_tags">
<div class="form-inline ml-auto d-none d-md-flex" t-if="search_slide_category or search_my or search_tags">
<a href="/slides/all" class="btn btn-info text-nowrap mr-2" role="button" title="Clear filters">
<i class="fa fa-eraser"/> Clear filters
</a>
@@ -244,7 +244,7 @@
<t t-set="placeholder">Search courses</t>
<t t-set="search" t-value="original_search or search_term"/>
<input t-if="search_my" type="hidden" name="my" t-att-value="1"/>
<input t-if="search_slide_type" type="hidden" name="slide_type" t-att-value="search_slide_type" />
<input t-if="search_slide_category" type="hidden" name="slide_category" t-att-value="search_slide_category" />
</t>
</div>
</nav>
@@ -267,19 +267,19 @@
<span class="align-items-baseline border d-inline-flex pl-2 rounded mb-2">
<i class="fa fa-tag mr-2 text-muted"/>
<t t-esc="search_term"/>
<a t-att-href="slide_query_url(tag=slugify_tags(search_tags.ids), my=search_my, slide_type=search_slide_type)" class="btn border-0 py-1 post_link" t-att-rel="search_tags and 'nofollow'">&#215;</a>
<a t-att-href="slide_query_url(tag=slugify_tags(search_tags.ids), my=search_my, slide_category=search_slide_category)" class="btn border-0 py-1 post_link" t-att-rel="search_tags and 'nofollow'">&#215;</a>
</span>
</t>
<t t-foreach="search_tags" t-as="tag">
<span class="align-items-baseline border d-inline-flex pl-2 rounded mb-2">
<i class="fa fa-tag mr-2 text-muted"/>
<t t-esc="tag.display_name"/>
<a t-att-href='slide_query_url(tag=slugify_tags(search_tags.ids, tag.id), my=search_my, search=search_term, slide_type=search_slide_type)' class="btn border-0 py-1 post_link" t-att-rel="search_tags and 'nofollow'">&#215;</a>
<a t-att-href='slide_query_url(tag=slugify_tags(search_tags.ids, tag.id), my=search_my, search=search_term, slide_category=search_slide_category)' class="btn border-0 py-1 post_link" t-att-rel="search_tags and 'nofollow'">&#215;</a>
</span>
</t>
</div>
<div class="container o_wslides_home_main pb-5">
<div t-if="not channels and not search_term and not search_slide_type and not search_my and not search_tags">
<div t-if="not channels and not search_term and not search_slide_category and not search_my and not search_tags">
<p class="h2">No Course created yet.</p>
<p groups="website_slides.group_website_slides_officer">Click on "New" in the top-right corner to write your first course.</p>
</div>
@@ -333,10 +333,10 @@
<div t-if="channel_frontend_tags" class="mt-2 pt-1 o_wslides_desc_truncate_2">
<t t-foreach="channel_frontend_tags" t-as="tag">
<t t-if="search_tags">
<a t-att-href="slide_query_url(tag=slugify_tags(search_tags.ids, toggle_tag_id=tag.id), my=search_my, search=search_term, slide_type=search_slide_type)" t-attf-class="badge post_link #{'badge-primary' if tag in search_tags else 'o_wslides_channel_tag o_tag_color_0'}" t-att-rel="search_tags and 'nofollow'" t-esc="tag.name"/>
<a t-att-href="slide_query_url(tag=slugify_tags(search_tags.ids, toggle_tag_id=tag.id), my=search_my, search=search_term, slide_category=search_slide_category)" t-attf-class="badge post_link #{'badge-primary' if tag in search_tags else 'o_wslides_channel_tag o_tag_color_0'}" t-att-rel="search_tags and 'nofollow'" t-esc="tag.name"/>
</t>
<t t-else="">
<a t-att-href="slide_query_url(tag=slugify_tags(search_tags.ids, toggle_tag_id=tag.id), my=search_my, search=search_term, slide_type=search_slide_type)" t-attf-class="badge post_link o_wslides_channel_tag #{'o_tag_color_'+str(tag.color)}" t-att-rel="search_tags and 'nofollow'" t-esc="tag.name"/>
<a t-att-href="slide_query_url(tag=slugify_tags(search_tags.ids, toggle_tag_id=tag.id), my=search_my, search=search_term, slide_category=search_slide_category)" t-attf-class="badge post_link o_wslides_channel_tag #{'o_tag_color_'+str(tag.color)}" t-att-rel="search_tags and 'nofollow'" t-esc="tag.name"/>
</t>
</t>
</div>
@@ -144,17 +144,15 @@
<t t-set="can_access" t-value="aside_slide.is_preview or is_member or slide.channel_id.can_publish"/>
<li class="p-0 pb-1">
<a t-att-href="'/slides/slide/%s' % (slug(aside_slide)) if can_access else '#'"
t-att-class="'o_wslides_lesson_aside_list_link d-flex align-items-top px-2 pt-1 text-decoration-none %s%s' % (('bg-100 py-1 active' if aside_slide == slide else ''), 'text-muted' if not can_access else '')">
<div t-if="is_member" >
<i t-att-id="'o_wslides_lesson_aside_slide_check_%s' % (aside_slide.id)"
t-att-class="'mr-1 fa fa-fw %s' % ('text-success fa-check-circle' if channel_progress[aside_slide.id].get('completed') else 'text-600 fa-circle')">
</i>
</div>
<div class="o_wslides_lesson_link_name">
<t t-call="website_slides.slide_icon">
<t t-set="slide" t-value="aside_slide"/>
</t>
<span t-esc="aside_slide.name" class="mr-2"/>
t-att-class="'o_wslides_lesson_aside_list_link d-flex align-items-center px-2 py-1 text-decoration-none %s%s' % (('bg-100 active' if aside_slide == slide else ''), 'text-muted' if not can_access else '')">
<i t-if="is_member" t-att-id="'o_wslides_lesson_aside_slide_check_%s' % (aside_slide.id)"
t-att-class="'mr-2 fa text-left %s' % ('text-success fa-check-circle' if channel_progress[aside_slide.id].get('completed') else 'text-600 fa-circle')">
</i>
<t t-call="website_slides.slide_icon">
<t t-set="slide" t-value="aside_slide"/>
</t>
<div class="o_wslides_lesson_link_name text-truncate" t-att-title="aside_slide.name">
<span t-esc="aside_slide.name" class="mr-2 text-truncate"/>
</div>
<div class="ml-auto" t-if="aside_slide.question_ids">
<span t-att-class="'badge badge-pill %s' % ('badge-success' if channel_progress[aside_slide.id].get('completed') else 'badge-light text-600')">
@@ -187,10 +185,10 @@
</li>
</div>
<li class="pl-4">
<a t-if="can_access and aside_slide.question_ids and aside_slide.slide_type != 'quiz'" t-att-href="'/slides/slide/%s#lessonQuiz' % (slug(aside_slide))" class="o_wslides_lesson_aside_list_link text-decoration-none small text-600">
<a t-if="can_access and aside_slide.question_ids and aside_slide.slide_category != 'quiz'" t-att-href="'/slides/slide/%s#lessonQuiz' % (slug(aside_slide))" class="o_wslides_lesson_aside_list_link text-decoration-none small text-600">
<i class="fa fa-flag text-warning"/> Quiz
</a>
<span t-elif="not can_access and aside_slide.question_ids and aside_slide.slide_type != 'quiz'"
<span t-elif="not can_access and aside_slide.question_ids and aside_slide.slide_category != 'quiz'"
class="o_wslides_lesson_aside_list_link text-decoration-none small text-600 text-muted">
<i class="fa fa-flag text-warning"/> Quiz
</span>
@@ -231,7 +229,7 @@
t-att-href="'/slides/slide/%s' % (slug(previous_slide)) if previous_slide else '#'">
<i class="fa fa-chevron-left mr-2"></i> <span class="d-none d-sm-inline-block">Prev</span>
</a>
<t t-set="allow_done_btn" t-value="slide.slide_type in ['infographic', 'presentation', 'document', 'webpage', 'video'] and not slide.question_ids and not channel_progress[slide.id].get('completed') and slide.channel_id.is_member"/>
<t t-set="allow_done_btn" t-value="slide.slide_category in ['infographic', 'document', 'article', 'video'] and not slide.question_ids and not channel_progress[slide.id].get('completed') and slide.channel_id.is_member"/>
<a t-att-class="'btn btn-primary border text-white %s' % ('disabled' if not allow_done_btn else '')"
role="button" t-att-aria-disabled="'true' if not allow_done_btn else None"
t-att-href="'/slides/slide/%s/set_completed?%s' % (slide.id, 'next_slide_id=%s' % (next_slide.id) if next_slide else '') if allow_done_btn else '#'">
@@ -255,16 +253,21 @@
</t>
</div>
<div class="o_wslides_lesson_content_type">
<img t-if="slide.slide_type == 'infographic'"
<img t-if="slide.slide_category == 'infographic'"
t-att-src="website.image_url(slide, 'image_1024')" class="img-fluid" style="width:100%" t-att-alt="slide.name"/>
<div t-if="slide.slide_type in ('presentation', 'document')" class="embed-responsive embed-responsive-4by3 embed-responsive-item mb8" style="height: 600px;">
<div t-if="slide.slide_category == 'document'" class="embed-responsive embed-responsive-4by3 embed-responsive-item mb8" style="height: 600px;">
<t t-out="slide.embed_code"/>
</div>
<div t-if="slide.slide_type == 'video' and slide.document_id" class="embed-responsive embed-responsive-16by9 embed-responsive-item mb8">
<div t-if="slide.slide_category == 'video'" class="embed-responsive embed-responsive-16by9 embed-responsive-item mb8">
<t t-out="slide.embed_code"/>
</div>
<div t-if="slide.slide_type == 'webpage'" class="bg-white p-3">
<div t-field="slide.html_content"/>
<div t-if="slide.slide_category == 'article'">
<div t-if="is_html_empty(slide.html_content)" class="alert alert-info">
Click on the "Edit" button on the top-right of the screen to edit your slide content.
</div>
<div class="bg-white p-3">
<div t-field="slide.html_content"/>
</div>
</div>
</div>
@@ -400,7 +403,7 @@
</div>
</div>
<div class="o_wslides_js_quiz_container" t-att-data-slide-id="slide.id">
<div class="row" t-if="slide.slide_type != 'certification'">
<div class="row" t-if="slide.slide_category != 'certification'">
<t t-if="slide.question_ids">
<t t-call="website_slides.lesson_content_quiz"/>
</t>
@@ -444,7 +447,7 @@
<div class="o_wslides_js_lesson_quiz col" id="lessonQuiz"
t-att-data-id="slide.id"
t-att-data-name="slide.name"
t-att-data-slide-type="slide.slide_type"
t-att-data-slide-category="slide.slide_category"
t-att-data-is-member="slide.channel_id.is_member"
t-att-data-completed="1 if slide_completed else 0"
t-att-data-quiz-attempts-count="quiz_attempts_count"
@@ -543,7 +546,7 @@
</form>
</div>
<div t-if="is_public_user" class="alert alert-info d-inline-block">
<p class="mb-0">Please <a t-attf-href="/web?redirect=#{request.httprequest.url}" class="font-weight-bold"> login </a> to share this <t t-esc="slide.slide_type"/> by email.</p>
<p class="mb-0">Please <a t-attf-href="/web?redirect=#{request.httprequest.url}" class="font-weight-bold"> login </a> to share this <t t-esc="slide.slide_category"/> by email.</p>
</div>
</template>
@@ -557,7 +560,7 @@
<t t-esc="slide.embed_code_external"/>
</textarea>
</div>
<div t-if="slide.slide_type in ('presentation', 'document') and not embed_hide_starting_page" class="form-group d-flex">
<div t-if="slide.slide_category == 'document' and not embed_hide_starting_page" class="form-group d-flex">
<div class="form-text p-0 col-xs-5 col-sm-5 col-md-5 col-lg-5">Select page to start with</div>
<div class="input-group col-xs-3 col-sm-2 col-md-2 col-lg-3">
<input type="number" value="1" class="form-control"/>
@@ -71,7 +71,7 @@
</t>
</div>
</div>
<t t-if="slide.slide_type in ('presentation', 'document')">
<t t-if="slide.slide_category == 'document'">
<div id="PDFViewerLoader" class="oe_slides_loader mt-3 mx-2 w-100">
<div class="toast show mx-auto">
<div class="toast-header">
@@ -84,12 +84,12 @@
</div>
<canvas id="PDFViewerCanvas" class="mx-auto" style="display: none;"></canvas>
</t>
<t t-if="slide.slide_type == 'infographic'">
<t t-if="slide.slide_category == 'infographic'">
<img t-att-src="website.image_url(slide, 'image_1024')" class="img-fluid" style="width: 100%" alt="Slide image"/>
</t>
</div>
<!-- Fixed bottom navbar -->
<div id="PDFViewerNav" class="pt-2 pb-2 bg-light text-white" role="navigation" t-if="slide.slide_type in ('presentation', 'document')">
<div id="PDFViewerNav" class="pt-2 pb-2 bg-light text-white" role="navigation" t-if="slide.slide_category == 'document'">
<div class="container-fluid oe_slides_panel_footer">
<div class="row align-items-center">
<div class="col-3 d-flex align-items-center">
@@ -111,7 +111,7 @@
<span id="previous" class="mx-1 mx-sm-2" title="Previous slide" aria-label="Previous slide" role="button"><i class="fa fa-arrow-circle-left"/></span>
<span id="next" class="mx-1 mx-sm-2" title="Next slide" aria-label="Next slide" role="button"><i class="fa fa-arrow-circle-right"/></span>
<span id="last" class="mx-1 mx-sm-2" title="Last slide" aria-label="Last slide" role="button"><i class="fa fa-step-forward"/></span>
<a t-if="slide.slide_resource_downloadable" id="download" t-attf-href="/web/content/slide.slide/#{slide.id}/datas?download=true"
<a t-if="slide.slide_resource_downloadable" id="download" t-attf-href="/web/content/slide.slide/#{slide.id}/binary_content?download=true"
class="ml-1 ml-sm-2" title="Download Content" aria-label="Download" role="button">
<i class="fa fa-download" />
</a>
@@ -109,12 +109,13 @@
t-att-data-id="slide.id"
t-att-data-can-access="can_access"
t-att-data-name="slide.name"
t-att-data-type="slide.slide_type"
t-att-data-category="slide.slide_category"
t-att-data-video-source-type="slide.video_source_type"
t-att-data-slug="slug(slide)"
t-att-data-has-question="1 if slide.question_ids else 0"
t-att-data-is-quiz="0"
t-att-data-completed="1 if slide_completed else 0"
t-att-data-embed-code="slide.embed_code if slide.slide_type in ['video', 'document', 'presentation', 'infographic'] else False"
t-att-data-embed-code="slide.embed_code if slide.slide_category in ['video', 'document', 'infographic'] else False"
t-att-data-is-member="is_member"
t-att-data-session-answers="session_answers">
<span class="ml-3">
@@ -122,20 +123,21 @@
<i t-if="not slide_completed and is_member" class="fa fa-circle-thin fa-fw" t-att-data-slide-id="slide.id"/>
</span>
<div class="ml-2">
<a t-if="can_access" class="d-block pt-1" href="#">
<a t-if="can_access" class="d-block" href="#">
<div class="d-flex ">
<t t-set="icon_class" t-value="'mr-2'"/>
<t t-call="website_slides.slide_icon"/>
<div class="o_wslides_fs_slide_name" t-esc="slide.name"/>
</div>
</a>
<span t-else="" class="d-block pt-1" href="#">
<span t-else="" class="d-block" href="#">
<div class="d-flex ">
<t t-set="icon_class" t-value="'mr-2 text-600'"/>
<t t-call="website_slides.slide_icon"/>
<div class="o_wslides_fs_slide_name text-600" t-esc="slide.name"/>
</div>
</span>
<ul class="list-unstyled w-100 pt-2 small" t-if="slide.slide_resource_ids or (slide.question_ids and not slide.slide_type =='quiz')" >
<ul class="list-unstyled w-100 pt-2 small" t-if="slide.slide_resource_ids or (slide.question_ids and not slide.slide_category =='quiz')" >
<t t-set="links" t-value="slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'url')"/>
<li t-if="links" t-foreach="links" t-as="link" class="pl-0 mb-1">
<a t-if="can_access" class="o_wslides_fs_slide_link" t-att-href="link.link" target="_blank">
@@ -159,11 +161,12 @@
<t t-call="website_slides.join_course_link"/>
</li>
</div>
<li class="o_wslides_fs_sidebar_list_item pl-0 mb-1" t-if="slide.question_ids and not slide.slide_type == 'quiz'"
<li class="o_wslides_fs_sidebar_list_item pl-0 mb-1" t-if="slide.question_ids and not slide.slide_category == 'quiz'"
t-att-data-id="slide.id"
t-att-data-can-access="can_access"
t-att-data-video-source-type="slide.video_source_type"
t-att-data-name="slide.name"
t-att-data-type="slide.slide_type"
t-att-data-category="slide.slide_category"
t-att-data-slug="slug(slide)"
t-att-data-has-question="1 if slide.question_ids else 0"
t-att-data-is-quiz="1"
@@ -43,7 +43,7 @@ class WebsiteSlidesSurvey(WebsiteSlides):
@http.route(['/slides/add_slide'], type='json', auth='user', methods=['POST'], website=True)
def create_slide(self, *args, **post):
create_new_survey = post['slide_type'] == "certification" and post.get('survey') and not post['survey']['id']
create_new_survey = post['slide_category'] == "certification" and post.get('survey') and not post['survey']['id']
linked_survey_id = int(post.get('survey', {}).get('id') or 0)
if create_new_survey:
@@ -87,7 +87,7 @@ class WebsiteSlidesSurvey(WebsiteSlides):
# Utils
# ---------------------------------------------------
def _set_completed_slide(self, slide):
if slide.slide_type == 'certification':
if slide.slide_category == 'certification':
raise werkzeug.exceptions.Forbidden(_("Certification slides are completed when the survey is succeeded."))
return super(WebsiteSlidesSurvey, self)._set_completed_slide(slide)
@@ -6,7 +6,7 @@
<record id="website_slides.badge_data_certification_goal" model="gamification.goal.definition">
<field name="domain">[
('survey_scoring_success', '=', True),
('slide_id.slide_type', '=', 'certification')
('slide_id.slide_category', '=', 'certification')
]</field>
</record>
</data></odoo>
@@ -7,7 +7,7 @@
<field name="name">Furniture Creation Certification</field>
<field name="sequence">7</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_furniture.jpg"/>
<field name="slide_type">certification</field>
<field name="slide_category">certification</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
<field name="category_id" ref="website_slides.slide_category_demo_5_0"/>
<field name="survey_id" ref="website_slides_survey.furniture_certification"/>
@@ -23,7 +23,7 @@
<field name="name">DIY Furniture Certification</field>
<field name="sequence">1</field>
<field name="image_1920" type="base64" file="website_slides/static/src/img/slide_demo_furniture.jpg"/>
<field name="slide_type">certification</field>
<field name="slide_category">certification</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_6_furn3"/>
<field name="category_id" eval="False"/>
<field name="survey_id" ref="website_slides_survey.furniture_certification"/>
@@ -30,27 +30,45 @@ class SlidePartnerRelation(models.Model):
class Slide(models.Model):
_inherit = 'slide.slide'
slide_type = fields.Selection(selection_add=[
slide_category = fields.Selection(selection_add=[
('certification', 'Certification')
], ondelete={'certification': 'set default'})
slide_type = fields.Selection(selection_add=[
('certification', 'Certification')
], ondelete={'certification': 'set null'})
survey_id = fields.Many2one('survey.survey', 'Certification')
nbr_certification = fields.Integer("Number of Certifications", compute='_compute_slides_statistics', store=True)
# small override of 'is_preview' to uncheck it automatically for slides of type 'certification'
is_preview = fields.Boolean(compute='_compute_is_preview', readonly=False, store=True)
_sql_constraints = [
('check_survey_id', "CHECK(slide_type != 'certification' OR survey_id IS NOT NULL)", "A slide of type 'certification' requires a certification."),
('check_certification_preview', "CHECK(slide_type != 'certification' OR is_preview = False)", "A slide of type certification cannot be previewed."),
('check_survey_id', "CHECK(slide_category != 'certification' OR survey_id IS NOT NULL)", "A slide of type 'certification' requires a certification."),
('check_certification_preview', "CHECK(slide_category != 'certification' OR is_preview = False)", "A slide of type certification cannot be previewed."),
]
@api.depends('slide_category')
def _compute_is_preview(self):
for slide in self:
if slide.slide_category == 'certification' or not slide.is_preview:
slide.is_preview = False
@api.onchange('survey_id')
def _on_change_survey_id(self):
if self.survey_id:
self.slide_type = 'certification'
self.slide_category = 'certification'
@api.depends('slide_category', 'source_type')
def _compute_slide_type(self):
super(Slide, self)._compute_slide_type()
for slide in self:
if slide.slide_category == 'certification':
slide.slide_type = 'certification'
@api.model
def create(self, values):
rec = super(Slide, self).create(values)
if rec.survey_id:
rec.slide_type = 'certification'
rec.slide_category = 'certification'
if 'survey_id' in values:
rec._ensure_challenge_category()
return rec
@@ -90,7 +108,7 @@ class Slide(models.Model):
course can be enrolled multiple times.
"""
certification_urls = {}
for slide in self.filtered(lambda slide: slide.slide_type == 'certification' and slide.survey_id):
for slide in self.filtered(lambda slide: slide.slide_category == 'certification' and slide.survey_id):
if slide.channel_id.is_member:
user_membership_id_sudo = slide.user_membership_id.sudo()
if user_membership_id_sudo.user_input_ids:
@@ -11,7 +11,7 @@ Fullscreen.include({
),
/**
* Extend the _renderSlide method so that slides of type "certification"
* Extend the _renderSlide method so that slides of category "certification"
* are also taken into account and rendered correctly
*
* @private
@@ -20,7 +20,7 @@ Fullscreen.include({
_renderSlide: function (){
var def = this._super.apply(this, arguments);
var $content = this.$('.o_wslides_fs_content');
if (this.get('slide').type === "certification"){
if (this.get('slide').category === "certification"){
$content.html(QWeb.render('website.slides.fullscreen.certification',{widget: this}));
}
return Promise.all([def]);
@@ -7,7 +7,7 @@ var sessionStorage = window.sessionStorage;
var SlidesUpload = require('@website_slides/js/slides_upload')[Symbol.for("default")];
/**
* Management of the new 'certification' slide_type
* Management of the new 'certification' slide_category
*/
SlidesUpload.SlideUploadDialog.include({
events: _.extend({}, SlidesUpload.SlideUploadDialog.prototype.events || {}, {
@@ -32,14 +32,14 @@ SlidesUpload.SlideUploadDialog.include({
//--------------------------------------------------------------------------
/**
* Overridden to add the "certification" slide type
* Overridden to add the "certification" slide category
*
* @override
* @private
*/
_setup: function () {
this._super.apply(this, arguments);
this.slide_type_data['certification'] = {
this.slide_category_data['certification'] = {
icon: 'fa-trophy',
label: _t('Certification'),
template: 'website.slide.upload.modal.certification',
@@ -3,14 +3,14 @@
<t t-name="website.slides.fullscreen.certification">
<div class="justify-content-center align-self-center">
<div t-if="widget.get('slide').type === 'certification' &amp;&amp; !widget.get('slide').completed" class="">
<div t-if="widget.get('slide').category === 'certification' &amp;&amp; !widget.get('slide').completed" class="">
<a class="btn btn-primary" t-att-href="'/slides_survey/slide/get_certification_url?slide_id=' + widget.get('slide').id" target="_blank">
<i class="fa fa-trophy"/>
<span t-if="widget.get('slide').isMember"> Pass Certification</span>
<span t-else="">Test Certification</span>
</a>
</div>
<div class="" t-if="widget.get('slide').type === 'certification' &amp;&amp; widget.get('slide').completed">
<div class="" t-if="widget.get('slide').category === 'certification' &amp;&amp; widget.get('slide').completed">
<a role="button" class="btn btn-primary" t-att-href="'/survey/' + widget.get('slide').certificationId + '/get_certification'">
<i class="fa fa-fw fa-trophy" role="img" aria-label="Download certification" title="Download certification"/> Download certification
</a>
@@ -20,7 +20,7 @@
<t t-extend="website.slides.fullscreen.title">
<t t-jquery=".o_wslides_fs_slide_title_span" t-operation="before">
<i t-if="widget.get('slide').type === 'certification'" class="fa fa-trophy mr-2 text"></i>
<i t-if="widget.get('slide').category === 'certification'" class="fa fa-trophy mr-2 text"></i>
</t>
</t>
</templates>
@@ -47,11 +47,11 @@ class TestCourseCertificationFailureFlow(TestSurveyCommon):
'is_published': True,
})
# Step 2: link the certification to a slide of type 'certification'
# Step 2: link the certification to a slide of category 'certification'
self.slide_certification = self.env['slide.slide'].sudo().create({
'name': 'Certification slide',
'channel_id': self.channel.id,
'slide_type': 'certification',
'slide_category': 'certification',
'survey_id': certification.id,
'is_published': True,
})
@@ -10,11 +10,11 @@
<span class="o_stat_text" attrs="{'invisible': [('nbr_certification', '>', 0)]}">Finished</span>
<span class="o_stat_text" attrs="{'invisible': [('nbr_certification', '=', 0)]}">Certified</span>
</xpath>
<xpath expr="//field[@name='slide_type']" position="after">
<xpath expr="//field[@name='slide_category']" position="after">
<field name="survey_id"/>
</xpath>
<xpath expr="//create[@name='add_slide_lesson']" position="after">
<create name="add_slide_certificate" string="Add Certification" groups="survey.group_survey_user" context="{'default_slide_type': 'certification'}"/>
<create name="add_slide_certificate" string="Add Certification" groups="survey.group_survey_user" context="{'default_slide_category': 'certification'}"/>
</xpath>
</field>
</record>
@@ -5,11 +5,14 @@
<field name="model">slide.slide</field>
<field name="inherit_id" ref="website_slides.view_slide_slide_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='slide_type']" position="after">
<xpath expr="//field[@name='slide_category']" position="after">
<field name="survey_id"
attrs="{'invisible': [('slide_type', '!=', 'certification')], 'required': [('slide_type', '=', 'certification')]}"
attrs="{'invisible': [('slide_category', '!=', 'certification')], 'required': [('slide_category', '=', 'certification')]}"
domain="[('certification', '=', True)]" context="{'default_certification': True, 'default_scoring_type': 'scoring_without_answers'}"/>
</xpath>
<xpath expr="//field[@name='is_preview']" position="attributes">
<attribute name="attrs">{'invisible': [('slide_category', '=', 'certification')]}</attribute>
</xpath>
</field>
</record>
@@ -17,8 +20,8 @@
<field name="name">Certifications</field>
<field name="res_model">slide.slide</field>
<field name="view_mode">tree,form,graph</field>
<field name="domain">[('slide_type', '=', 'certification')]</field>
<field name="context">{'default_slide_type': 'certification'}</field>
<field name="domain">[('slide_category', '=', 'certification')]</field>
<field name="context">{'default_slide_category': 'certification'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new certification
@@ -45,7 +45,7 @@
<div class="text-muted d-inline-block">No certifications yet!</div>
</t>
<div class="text-right d-inline-block pull-right">
<a href="/slides/all?slide_type=certification" class="btn btn-link btn-sm"><i class="fa fa-arrow-right mr-1"/>All Certifications</a>
<a href="/slides/all?slide_category=certification" class="btn btn-link btn-sm"><i class="fa fa-arrow-right mr-1"/>All Certifications</a>
</div>
</template>
@@ -11,7 +11,7 @@
<div t-if="channel.nbr_certification > 0 and channel.is_member and channel.completion == 0" class="alert alert-success d-flex align-items-center justify-content-between flex-wrap">
<div>Begin your <b>certification</b> today!</div>
<a t-attf-href="#{'/slides_survey/slide/get_certification_url?slide_id=%s' %(first_slide.id) if first_slide.slide_type == 'certification' and channel.total_slides == 1 else '/slides/slide/%s?fullscreen=1' %(slug(first_slide))}" class="btn btn-success mt-2 mt-sm-0">
<a t-attf-href="#{'/slides_survey/slide/get_certification_url?slide_id=%s' %(first_slide.id) if first_slide.slide_category == 'certification' and channel.total_slides == 1 else '/slides/slide/%s?fullscreen=1' %(slug(first_slide))}" class="btn btn-success mt-2 mt-sm-0">
<span>Start Now</span><i class="fa fa-chevron-right ml-2 align-middle"/>
</a>
</div>
@@ -26,12 +26,12 @@
<template id="course_slides_list_slide_inherit_survey" inherit_id="website_slides.course_slides_list_slide">
<xpath expr="//a[hasclass('o_wslides_js_slides_list_slide_link')]" position="attributes">
<attribute name="t-attf-href">#{'/slides_survey/slide/get_certification_url?slide_id=%s' %(slide.id) if slide.slide_type == 'certification' and slide.channel_id.total_slides == 1 else '/slides/slide/%s' %(slug(slide))}</attribute>
<attribute name="t-attf-href">#{'/slides_survey/slide/get_certification_url?slide_id=%s' %(slide.id) if slide.slide_category == 'certification' and slide.channel_id.total_slides == 1 else '/slides/slide/%s' %(slug(slide))}</attribute>
</xpath>
<xpath expr="//a[@name='o_wslides_list_slide_add_quizz']" position="attributes">
<attribute name="t-if">channel.can_upload and not slide.question_ids and slide.slide_type != 'certification'</attribute>
<attribute name="t-if">channel.can_upload and not slide.question_ids and slide.slide_category != 'certification'</attribute>
</xpath>
<xpath expr="//a[@t-if='channel.can_upload']" position="attributes">
<xpath expr="//a[@name='o_wslides_slide_toggle_is_preview']" position="attributes">
<attribute name="t-attf-class">#{'d-none' if slide.slide_type == 'certification' else ''}</attribute>
</xpath>
</template>
@@ -3,7 +3,7 @@
<data>
<template id="courses_home_inherit_survey" inherit_id="website_slides.courses_home">
<xpath expr="//a[hasclass('o_wslides_home_all_slides')]" position="after">
<a class="nav-link nav-link d-flex" href="/slides/all?slide_type=certification">
<a class="nav-link nav-link d-flex" href="/slides/all?slide_category=certification">
<t t-call="website_slides_survey.o_wss_certification_icon"/>
<span class="ml-1">Certifications</span>
</a>
@@ -3,7 +3,7 @@
<data>
<template id="slide_content_detailed" inherit_id="website_slides.slide_content_detailed">
<xpath expr="//div[hasclass('o_wslides_lesson_content_type')]" position="inside">
<div t-if="slide.slide_type == 'certification' and not channel_progress[slide.id].get('completed')" class="col mt32 mb8 d-flex justify-content-center">
<div t-if="slide.slide_category == 'certification' and not channel_progress[slide.id].get('completed')" class="col mt32 mb8 d-flex justify-content-center">
<a role="button"
class="btn btn-primary btn-lg"
t-att-href="'/slides_survey/slide/get_certification_url?slide_id=%s' %(slide.id)">
@@ -12,7 +12,7 @@
<t t-else="">Test Certification</t>
</a>
</div>
<div t-if="slide.slide_type == 'certification' and channel_progress[slide.id].get('completed')" class="col mt32 mb8 d-flex justify-content-center">
<div t-if="slide.slide_category == 'certification' and channel_progress[slide.id].get('completed')" class="col mt32 mb8 d-flex justify-content-center">
<a role="button" class="btn btn-primary btn-lg" t-att-href="'/survey/%s/get_certification' % slide.survey_id.id">
<i class="fa fa-fw fa-trophy" role="img" aria-label="Download certification" title="Download certification"/> Download certification
</a>