[REF] website_slides: refactor external content management in slide.slide

PURPOSE

Globally refactor the way we handle external content (YouTube videos,
Google Drive documents, ...) in the course slides.

This includes both technical changes (new computed fields, rename, ...) as well
as visual changes on the form views that allow introducing new content, both in
the backend (slide.slide form view) and in the frontend (course main website
page).

SPECS

TECHNICAL REFACTOR

1. Rework the "url" field

This field was used without any distinction for videos, regular documents and
images, leading to a big confusion both technically and visually.

The urls are now split in separate fields (video_url, document_google_url and
image_google_url), based on the type of slide.
Those fields are implemented as 'related' to the stored 'url' field to avoid
extra storage.

This change globally increases the readability and usability in the code, and
also allows for clear labels on the various views.

1b. Rework the "binary_content" field

The same way as for the 'url' field, the 'binary_content' field has been split
into 2 related fields ('document_binary_content' and 'image_binary_content').

This allows for more readability and also helps in the form view to limit the
file selection to the supported file types.

2. Remove the "document_id" stored field

This field did not have to be stored since it can "easily" be inferred from the
video/document URL with some regex work.

3. Remove the "mime_type" field

This field's value was only determined by an external call to the Google Drive
API. It was inconsistent because not set for manually uploaded document and was
used for some strange checks that seemed to work but did not make much sense.
(e.g: used to make the difference between a Google Drive video and a YouTube
video??).

It has been replaced by a "document_type" field, which is always 'pdf' for
local files (only type we support) and inferred from the mimeType returned by
Google Drive metadata for external documents.

This allows, for instance, to show pretty icons next to every slide based on
their type, "sheet" files (Excel, Google Sheet, ...) share the same icons, the
same principle is applied to "doc" files (Word, OpenOffice, Google Doc, ...)
and finally "slides" files (PowerPoint, Google Slides, ...).

4. Introduce several computed fields

Such as:
- video_source_type
- youtube_id
- google_drive_id

These fields are mostly inferred from the slide type and the related URL.
They are used to make clear checks in the code and ease readability, for
example when constructing the slide "embed code" to insert it in the frontend
full screen viewer.

5. Remove the "presentation" slide_type

The slide type "presentation" was some kind of very confusing type between
image and pdf. It was mostly relying on the fact that the content had a bigger
width than height ('landscape' display).

This lead to a lot of confusion both in the code base and in the various forms
allowing to introduce new content ("what should I choose? document or
presentation?").

In order to simplify everything, we completely removed the slide type
"presentation" and converted all the data that had this type and the various
code checks to the type "document" instead.

Meaning that we end up with the following slide types:
- Infographic - for images, local files + external links
- Web Page - local input only
- Document local files + external links
- Video - external only, YouTube or Google Drive links
- Quiz

FUNCTIONAL

Following the technical changes here above, we can improve the form view of the
slide.slide model as well as the website form that allows adding new content.

1. Slide form view

To make it clear where the source of the document comes from, we introduced a
"source_type" field that is either "local_file", used when uploading a file
from your computer, or "external", used when linking content from Google Drive.

The source_type selection appears when adding content of type "document" or
"infographic" and conditions the display of the file upload button or the
document_url field.

2. Adding content from the website

When adding content from the website, the user is now invited to select from
ALL the available slide type, meaning we added "Infographic" to the selection.

Before this change, if you wanted to link an image, you had to select the type
"presentation" (that has a PDF file as icon...) and then input an image file.
The JS then processed the file type to determine the slide type.

As this was slightly confusing for the end user, we decided to use the same
approach as the form view, meaning you FIRST select the type of slide you want
and THEN input the content as a local file or external link.

Since we now handle all types of documents when using an external Google Drive
link, the various screen and text helpers have been reworked accordingly.

3. Handle more types of slides with the new "slide_type" field

This commit also introduces a new "slide_type" field.
(Don't get confused, the previous "slide_type" field has been renamed to
"slide_category" in an earlier rename commit).

This slide_type is a refined slide_category:
- Videos are split into 'youtube_video' and 'google_drive_video'
- Documents are refined based on the file mime_type if it's external content
  For example, if you link a Excel file, the slide_type will be 'sheet'.
  If you link a Google Doc, the slide_type will be 'doc'
  etc...
  (Local documents are only PDFs since it's the only type we support for now)

This small change allows to include minor nice details into views, such as a
refined icon for each slide_type on the website.

LINKS

Task-2510174
UPG PR odoo/upgrade#2498

Part-of: odoo/odoo#71477
This commit is contained in:
Aurélien Warnon
2021-11-30 15:21:48 +00:00
parent ab0b36e8a5
commit 7b5427f6d6
23 changed files with 864 additions and 370 deletions
+1 -1
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',
+59 -17
View File
@@ -1053,22 +1053,64 @@ class WebsiteSlides(WebsiteProfile):
# --------------------------------------------------
@http.route(['/slides/prepare_preview'], type='json', auth='user', methods=['POST'], website=True)
def prepare_preview(self, **data):
def prepare_preview(self, channel_id, slide_category, url=None):
""" Will attempt to fetch external metadata for this slide from the correct
source (YouTube, Google Drive, ...).
To take advantage of the slide business method, we create a temporary slide record before
fetching the metadata.
This allows a lot of code simplification, since we use "new", it will not created anything
in database. """
if not url:
return {}
Slide = request.env['slide.slide']
unused, document_id = Slide._find_document_data_from_url(data['url'])
preview = {}
if not document_id:
preview['error'] = _('Please enter valid youtube or google doc url')
return preview
existing_slide = Slide.search([('channel_id', '=', int(data['channel_id'])), ('document_id', '=', document_id)], limit=1)
if existing_slide:
preview['error'] = _('This video already exists in this channel on the following slide: %s', existing_slide.name)
return preview
values = Slide._parse_document_url(data['url'], only_preview_fields=True)
if values.get('error'):
preview['error'] = values['error']
return preview
return values
if slide_category == 'video':
identical_video = request.env['slide.slide']
existing_videos = Slide.search([
('channel_id', '=', int(channel_id)),
('slide_category', '=', 'video')
])
slide = Slide.new({
'channel_id': int(channel_id),
'name': 'memory_record_for_computed_fields',
'slide_category': 'video',
'url': url
})
if not slide.video_source_type:
slide.unlink()
return {'error': _('Please enter valid YouTube or Google Drive Link')}
if slide.video_source_type == 'youtube':
identical_video = existing_videos.filtered(
lambda existing_video: slide.youtube_id == existing_video.youtube_id)
elif slide.video_source_type == 'google_drive':
identical_video = existing_videos.filtered(
lambda existing_video: slide.google_drive_id == existing_video.google_drive_id)
if identical_video:
identical_video_name = identical_video[0].name
return {'error': _('This video already exists in this channel on the following content: %s', identical_video_name)}
elif slide_category in ['document', 'infographic']:
slide = Slide.new({
'channel_id': int(channel_id),
'name': 'memory_record_for_computed_fields',
'slide_category': slide_category,
'source_type': 'external',
'url': url
})
if not slide.google_drive_id:
return {'error': _('Please enter valid Google Drive Link')}
slide_values, error = slide._fetch_external_metadata(image_url_only=True)
if error:
return {'error': error}
return slide_values
@http.route(['/slides/add_slide'], type='json', auth='user', methods=['POST'], website=True)
def create_slide(self, *args, **post):
@@ -1138,8 +1180,8 @@ class WebsiteSlides(WebsiteProfile):
}
def _get_valid_slide_post_values(self):
return ['name', 'url', 'tag_ids', 'slide_category', 'channel_id', 'is_preview',
'mime_type', 'binary_content', 'description', 'image_1920', 'is_published']
return ['name', 'url', 'video_url', 'document_google_url', 'image_google_url', 'tag_ids', 'slide_category', 'channel_id',
'is_preview', 'binary_content', 'description', 'image_1920', 'is_published', 'source_type']
@http.route(['/slides/tag/search_read'], type='json', auth='user', methods=['POST'], website=True)
def slide_tag_search_read(self, fields, domain):
@@ -8,7 +8,7 @@
<field name="sequence">1</field>
<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_category">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)"/>
@@ -211,7 +211,6 @@
<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_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="is_published" eval="True"/>
@@ -329,7 +328,7 @@
<field name="sequence">6</field>
<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_category">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,7 +343,6 @@
<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_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="is_published" eval="True"/>
@@ -380,7 +378,7 @@
<field name="sequence">1</field>
<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_category">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)"/>
@@ -512,7 +510,6 @@
<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_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
<field name="is_published" eval="True"/>
@@ -529,7 +526,7 @@
<field name="sequence">5</field>
<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_category">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)"/>
@@ -576,7 +573,6 @@
<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_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_3_furn0"/>
<field name="is_published" eval="True"/>
@@ -655,7 +651,6 @@
<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_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_4_furn1"/>
<field name="is_published" eval="True"/>
@@ -741,7 +736,7 @@
<field name="sequence">1</field>
<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_category">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,7 +763,6 @@
<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_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
<field name="is_published" eval="True"/>
@@ -783,7 +777,6 @@
<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_category">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
<field name="is_published" eval="True"/>
@@ -183,7 +183,6 @@ class Channel(models.Model):
)
promoted_slide_id = fields.Many2one('slide.slide', string='Promoted Slide')
access_token = fields.Char("Security Token", copy=False, default=_default_access_token)
nbr_presentation = fields.Integer('Presentations', compute='_compute_slides_statistics', store=True)
nbr_document = fields.Integer('Documents', compute='_compute_slides_statistics', store=True)
nbr_video = fields.Integer('Videos', compute='_compute_slides_statistics', store=True)
nbr_infographic = fields.Integer('Infographics', compute='_compute_slides_statistics', store=True)
+395 -200
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,12 @@ class Slide(models.Model):
}
_order = 'sequence asc, is_category asc, id asc'
YOUTUBE_VIDEO_ID_REGEX = r'^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*'
GOOGLE_DRIVE_DOCUMENT_ID_REGEX = r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)'
# description
name = fields.Char('Title', required=True, translate=True)
image_1920 = fields.Image(compute="_compute_image_1920", store=True, readonly=False) # image.mixin override
active = fields.Boolean(default=True, tracking=100)
sequence = fields.Integer('Sequence', default=0)
user_id = fields.Many2one('res.users', string='Uploaded by', default=lambda self: self.env.uid)
@@ -121,20 +125,54 @@ class Slide(models.Model):
slide_category = fields.Selection([
('infographic', 'Infographic'),
('webpage', 'Web Page'),
('presentation', 'Presentation'),
('document', 'Document'),
('video', 'Video'),
('quiz', "Quiz")],
string='Category', required=True,
default='document',
help="The document category will be set automatically based on the document URL and properties (e.g. height and width for presentation and document).")
default='document')
source_type = fields.Selection([
('local_file', 'Local File'),
('external', 'External (Google Drive)')],
default='local_file', required=True)
# generic
url = fields.Char('External URL', help="URL of the Google Drive file or URL of the YouTube video")
binary_content = fields.Binary('File', attachment=True)
url = fields.Char('Document URL', help="Youtube or Google Document URL")
document_id = fields.Char('Document ID', help="Youtube or Google Document ID")
slide_resource_ids = fields.One2many('slide.slide.resource', 'slide_id', string="Additional Resource for this slide")
slide_resource_downloadable = fields.Boolean('Allow Download', default=True, help="Allow the user to download the content of the slide.")
mime_type = fields.Char('Mime-type')
# google
google_drive_id = fields.Char('Google Drive ID of the external URL', compute='_compute_google_drive_id')
# content - webpage
html_content = fields.Html("HTML Content", help="Custom HTML content for slides of category 'Web Page'.", translate=True, sanitize_attributes=False, sanitize_form=False)
# content - images
image_binary_content = fields.Binary('Image Content', related='binary_content', readonly=False,
help="Used to filter file input to images only")
image_google_url = fields.Char('Image Link', related='url', readonly=False,
help="Link of the image (we currently only support Google Drive as source)")
# content - documents
slide_type = fields.Selection([
('image', 'Image'),
('webpage', 'Web Page'),
('quiz', 'Quiz'),
('pdf', 'PDF'),
('sheet', 'Sheet (Excel, Google Sheet, ...)'),
('doc', 'Document (Word, Google Doc, ...)'),
('slides', 'Slides (PowerPoint, Google Slides, ...)'),
('youtube_video', 'YouTube Video'),
('google_drive_video', 'Google Drive Video')],
string="Slide Type", compute='_compute_slide_type', store=True, readonly=False,
help="Subtype of the slide category, allows more precision on the actual file type / source type.")
document_google_url = fields.Char('Document Link', related='url', readonly=False,
help="Link of the document (we currently only support Google Drive as source)")
document_binary_content = fields.Binary('PDF Content', related='binary_content', readonly=False,
help="Used to filter file input to PDF only")
# content - videos
video_url = fields.Char('Video URL', related='url', readonly=False,
help="Link of the video (we support YouTube and Google Drive as sources)")
video_source_type = fields.Selection([
('youtube', 'YouTube'),
('google_drive', 'Google Drive')],
string='Video Source', compute="_compute_video_source_type")
youtube_id = fields.Char('Video YouTube ID', compute='_compute_youtube_id')
# website
website_id = fields.Many2one(related='channel_id.website_id', readonly=True)
date_published = fields.Datetime('Publish Date', readonly=True, tracking=False)
@@ -156,7 +194,6 @@ class Slide(models.Model):
channel_type = fields.Selection(related="channel_id.channel_type", string="Channel type")
channel_allow_comment = fields.Boolean(related="channel_id.allow_comment", string="Allows comment")
# Statistics in case the slide is a category
nbr_presentation = fields.Integer("Number of Presentations", compute='_compute_slides_statistics', store=True)
nbr_document = fields.Integer("Number of Documents", compute='_compute_slides_statistics', store=True)
nbr_video = fields.Integer("Number of Videos", compute='_compute_slides_statistics', store=True)
nbr_infographic = fields.Integer("Number of Infographics", compute='_compute_slides_statistics', store=True)
@@ -167,9 +204,17 @@ class Slide(models.Model):
website_published = fields.Boolean(tracking=False)
_sql_constraints = [
('exclusion_html_content_and_url', "CHECK(html_content IS NULL OR url IS NULL)", "A slide is either filled with a document url or HTML content. Not both.")
('exclusion_html_content_and_url', "CHECK(html_content IS NULL OR url IS NULL)", "A slide is either filled with a url or HTML content. Not both.")
]
@api.depends('slide_category', 'source_type', 'image_binary_content')
def _compute_image_1920(self):
for slide in self:
if slide.slide_category == 'infographic' and slide.source_type == 'local_file' and slide.image_binary_content:
slide.image_1920 = slide.image_binary_content
elif not slide.image_1920:
slide.image_1920 = False
@api.depends('date_published', 'is_published')
def _compute_is_new_slide(self):
for slide in self:
@@ -301,6 +346,33 @@ class Slide(models.Model):
result[cid]['total_slides'] += slide_category_count
return result
@api.depends('slide_category', 'source_type', 'video_source_type')
def _compute_slide_type(self):
""" For 'local content' or specific slide categories, the slide type is directly derived
from the slide category.
For external content, the slide type is determined from the metadata and the mime_type.
(See #_fetch_google_drive_metadata() for more details)."""
for slide in self:
if slide.slide_category == 'document':
if slide.source_type == 'local_file':
slide.slide_type = 'pdf'
elif slide.slide_type not in ['pdf', 'sheet', 'doc', 'slides']:
slide.slide_type = False
elif slide.slide_category == 'infographic':
slide.slide_type = 'image'
elif slide.slide_category == 'webpage':
slide.slide_type = 'webpage'
elif slide.slide_category == 'quiz':
slide.slide_type = 'quiz'
elif slide.slide_category == 'video' and slide.video_source_type == 'youtube':
slide.slide_type = 'youtube_video'
elif slide.slide_category == 'video' and slide.video_source_type == 'google_drive':
slide.slide_type = 'google_drive_video'
else:
slide.slide_type = False
@api.depends('slide_partner_ids.partner_id')
@api.depends('uid')
def _compute_user_membership_id(self):
@@ -315,70 +387,99 @@ class Slide(models.Model):
self.env['slide.slide.partner']
)
@api.depends('document_id', 'slide_category', 'mime_type')
@api.depends('slide_category', 'google_drive_id', 'video_source_type', 'youtube_id')
def _compute_embed_code(self):
base_url = request and request.httprequest.url_root
for record in self:
embed_code_external = False
if not base_url:
base_url = record.get_base_url()
request_base_url = request.httprequest.url_root if request else False
for slide in self:
base_url = request_base_url or slide.get_base_url()
if base_url[-1] == '/':
base_url = base_url[:-1]
if record.binary_content and (not record.document_id or record.slide_category in ['document', 'presentation']):
slide_url = base_url + url_for('/slides/embed/%s?page=1' % record.id)
slide_url_external = base_url + url_for('/slides/embed_external/%s?page=1' % record.id)
embed_code = False
embed_code_external = False
if slide.slide_category == 'video':
if slide.video_source_type == 'youtube':
query_params = urls.url_parse(slide.video_url).query
query_params = query_params + '&theme=light' if query_params else 'theme=light'
embed_code = Markup('<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.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_category == 'video' and record.document_id:
if not record.mime_type:
# embed youtube video
query = urls.url_parse(record.url).query
query = query + '&theme=light' if query else 'theme=light'
record.embed_code = Markup('<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'
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('url', 'document_google_url', 'image_google_url', 'video_url')
def _compute_google_drive_id(self):
""" Extracts the Google Drive ID from the url based on the slide category. """
@api.onchange('url')
for slide in self:
url = slide.url or slide.document_google_url or slide.image_google_url or slide.video_url
google_drive_id = False
if url:
match = re.match(self.GOOGLE_DRIVE_DOCUMENT_ID_REGEX, url)
if match and len(match.groups()) == 2:
google_drive_id = match.group(2)
slide.google_drive_id = google_drive_id
@api.onchange('url', 'document_google_url', 'image_google_url', 'video_url')
def _on_change_url(self):
self.ensure_one()
if self.url:
res = self._parse_document_url(self.url)
if res.get('error'):
raise UserError(res.get('error'))
values = res['values']
if not values.get('document_id'):
raise UserError(_('Please enter valid Youtube or Google Doc URL'))
for key, value in values.items():
self[key] = value
""" Keeping a 'onchange' because we want this behavior for the frontend.
Changing the document / video external URL will populate some metadata on the form view.
The slide metadata are also fetched in create / write overrides to ensure consistency. """
@api.onchange('binary_content')
def _on_change_binary_content(self):
""" For PDFs, we assume that it takes 5 minutes to read a page.
If the selected file is not a PDF, it is an image (You can
only upload PDF or Image file) then the slide_category is changed
into infographic and the uploaded binary_content is transfered to the
image field. (It avoids the infinite loading in PDF viewer)"""
if self.binary_content:
data = base64.b64decode(self.binary_content)
if data.startswith(b'%PDF-'):
pdf = PyPDF2.PdfFileReader(io.BytesIO(data), overwriteWarnings=False, strict=False)
try:
pdf.getNumPages()
except PyPDF2.utils.PdfReadError:
return
self.completion_time = (5 * len(pdf.pages)) / 60
else:
self.slide_category = 'infographic'
self.image_1920 = self.binary_content
self.binary_content = None
self.ensure_one()
if self.url or self.document_google_url or self.image_google_url or self.video_url:
slide_metadata, _error = self._fetch_external_metadata()
if slide_metadata:
self.update(slide_metadata)
@api.onchange('document_binary_content')
def _on_change_document_binary_content(self):
if self.slide_category == 'document' and self.source_type == 'local_file' and self.document_binary_content:
completion_time = self._get_completion_time_pdf(base64.b64decode(self.document_binary_content))
if completion_time:
self.completion_time = completion_time
@api.onchange('slide_category')
def _on_change_slide_category(self):
""" Prevents mis-match when ones uploads an image and then a pdf without saving the form. """
if self.slide_category != 'infographic' and self.image_binary_content:
self.image_binary_content = False
elif self.slide_category != 'document' and self.document_binary_content:
self.document_binary_content = False
@api.depends('name', 'channel_id.website_id.domain')
def _compute_website_url(self):
@@ -409,29 +510,32 @@ class Slide(models.Model):
# 'website_published' is handled by mixin
values['date_published'] = False
if values.get('slide_category') == 'infographic' and not values.get('image_1920'):
values['image_1920'] = values['binary_content']
if values.get('is_category'):
values['is_preview'] = True
values['is_published'] = True
if values.get('is_published') and not values.get('date_published'):
values['date_published'] = datetime.datetime.now()
if values.get('url') and not values.get('document_id'):
doc_data = self._parse_document_url(values['url']).get('values', dict())
for key, value in doc_data.items():
values.setdefault(key, value)
slide = super(Slide, self).create(values)
# avoid fetching external metadata when installing the module (i.e. for demo data)
# we also support a context key if you don't want to fetch the metadata when creating a slide
if any(values.get(url_param) for url_param in ['url', 'video_url', 'document_google_url', 'image_google_url']) \
and not self.env.context.get('install_mode') \
and not self.env.context.get('website_slides_skip_fetch_metadata'):
slide_metadata, _error = slide._fetch_external_metadata()
if slide_metadata:
# only update keys that are not set in the incoming values
slide.update({key: value for key, value in slide_metadata.items() if key not in values.keys()})
if not 'completion_time' not in values:
slide._on_change_document_binary_content()
if slide.is_published and not slide.is_category:
slide._post_publication()
return slide
def write(self, values):
if values.get('url') and values['url'] != self.url:
doc_data = self._parse_document_url(values['url']).get('values', dict())
for key, value in doc_data.items():
values.setdefault(key, value)
if values.get('is_category'):
values['is_preview'] = True
values['is_published'] = True
@@ -441,6 +545,20 @@ class Slide(models.Model):
self.date_published = datetime.datetime.now()
self._post_publication()
# avoid fetching external metadata when installing the module (i.e. for demo data)
# we also support a context key if you don't want to fetch the metadata when modifying a slide
if any(values.get(url_param) for url_param in ['url', 'video_url', 'document_google_url', 'image_google_url']) \
and not self.env.context.get('install_mode') \
and not self.env.context.get('website_slides_skip_fetch_metadata'):
slide_metadata, _error = self._fetch_external_metadata()
if slide_metadata:
# only update keys that are not set in the incoming values and for which we don't have a value yet
self.update({
key: value
for key, value in slide_metadata.items()
if key not in values.keys() and not any(slide[key] for slide in self)
})
if 'is_published' in values or 'active' in values:
# if the slide is published/unpublished, recompute the completion for the partners
self.slide_partner_ids._set_completed_callback()
@@ -741,165 +859,228 @@ class Slide(models.Model):
# Parsing methods
# --------------------------------------------------
@api.model
def _fetch_data(self, base_url, params, content_type=False):
result = {'values': dict()}
def _fetch_external_metadata(self, image_url_only=False):
self.ensure_one()
slide_metadata = {}
error = False
if self.slide_category == 'video' and self.video_source_type == 'youtube':
slide_metadata, error = self._fetch_youtube_metadata(image_url_only)
elif self.slide_category == 'video' and self.video_source_type == 'google_drive':
slide_metadata, error = self._fetch_google_drive_metadata(image_url_only)
elif self.slide_category in ['document', 'infographic'] and self.source_type == 'external':
# external documents & google drive videos share the same method currently
slide_metadata, error = self._fetch_google_drive_metadata(image_url_only)
return slide_metadata, error
def _fetch_youtube_metadata(self, image_url_only=False):
""" Fetches video metadata from the YouTube API.
Returns a dict containing video metadata with the following keys (matching slide.slide fields):
- 'name' matching the video title
- 'description' matching the video description
- 'image_1920' binary data of the video thumbnail
OR 'image_url' containing an external link to the thumbnail when 'image_url_only' param is True
- 'completion_time' matching the video duration
The received duration is under a special format (e.g: PT1M21S15, meaning 1h 21m 15s).
:param image_url_only: if True, will return 'image_url' instead of binary data
Typically used when displaying a slide preview to the end user.
:return a tuple (values, error) containing the values of the slide and a potential error
(e.g: 'Video could not be found') """
self.ensure_one()
google_app_key = self.env['website'].get_current_website().website_slide_google_app_key
error_message = False
try:
response = requests.get(base_url, timeout=3, params=params)
response = requests.get(
'https://www.googleapis.com/youtube/v3/videos',
timeout=3,
params={
'fields': 'items(id,snippet,contentDetails)',
'id': self.youtube_id,
'key': google_app_key,
'part': 'snippet,contentDetails'
}
)
response.raise_for_status()
if content_type == 'json':
result['values'] = response.json()
elif content_type in ('image', 'pdf'):
result['values'] = base64.b64encode(response.content)
else:
result['values'] = response.content
except requests.exceptions.HTTPError as e:
result['error'] = e.response.content
error_message = e.response.content
if 'application/json' in e.response.headers.get('content-type'):
json_response = e.response.json()
if json_response.get('error', {}).get('code') == 404:
return {}, _('Your video could not be found on YouTube, please check the link and/or privacy settings')
except requests.exceptions.ConnectionError as e:
result['error'] = str(e)
return result
error_message = str(e)
def _find_document_data_from_url(self, url):
url_obj = urls.url_parse(url)
if url_obj.ascii_host == 'youtu.be':
return ('youtube', url_obj.path[1:] if url_obj.path else False)
elif url_obj.ascii_host in ('youtube.com', 'www.youtube.com', 'm.youtube.com', 'www.youtube-nocookie.com'):
v_query_value = url_obj.decode_query().get('v')
if v_query_value:
return ('youtube', v_query_value)
split_path = url_obj.path.split('/')
if len(split_path) >= 3 and split_path[1] in ('v', 'embed'):
return ('youtube', split_path[2])
if not error_message:
response = response.json()
if response.get('error'):
error_message = response.get('error', {}).get('errors', [{}])[0].get('reason')
expr = re.compile(r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)')
arg = expr.match(url)
document_id = arg and arg.group(2) or False
if document_id:
return ('google', document_id)
if not response.get('items'):
error_message = _('Your video could not be found on YouTube, please check the link and/or privacy settings')
return (None, False)
def _parse_document_url(self, url, only_preview_fields=False):
document_source, document_id = self._find_document_data_from_url(url)
if document_source and hasattr(self, '_parse_%s_document' % document_source):
return getattr(self, '_parse_%s_document' % document_source)(document_id, only_preview_fields)
return {'error': _('Unknown document')}
def _parse_youtube_document(self, document_id, only_preview_fields):
""" If we receive a duration (YT video), we use it to determine the slide duration.
The received duration is under a special format (e.g: PT1M21S15, meaning 1h 21m 15s). """
key = self.env['website'].get_current_website().website_slide_google_app_key
fetch_res = self._fetch_data('https://www.googleapis.com/youtube/v3/videos', {'id': document_id, 'key': key, 'part': 'snippet,contentDetails', 'fields': 'items(id,snippet,contentDetails)'}, 'json')
if fetch_res.get('error'):
return {'error': self._extract_google_error_message(fetch_res.get('error'))}
values = {'slide_category': 'video', 'document_id': document_id}
items = fetch_res['values'].get('items')
if not items:
return {'error': _('Please enter valid Youtube or Google Doc URL')}
youtube_values = items[0]
if error_message:
_logger.warning('Could not fetch YouTube metadata: %s', error_message)
return {}, error_message
slide_metadata = {'slide_type': 'youtube_video'}
youtube_values = response.get('items')[0]
youtube_duration = youtube_values.get('contentDetails', {}).get('duration')
if youtube_duration:
parsed_duration = re.search(r'^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$', youtube_duration)
if parsed_duration:
values['completion_time'] = (int(parsed_duration.group(1) or 0)) + \
(int(parsed_duration.group(2) or 0) / 60) + \
(int(parsed_duration.group(3) or 0) / 3600)
slide_metadata['completion_time'] = (int(parsed_duration.group(1) or 0)) + \
(int(parsed_duration.group(2) or 0) / 60) + \
(int(parsed_duration.group(3) or 0) / 3600)
if youtube_values.get('snippet'):
snippet = youtube_values['snippet']
if only_preview_fields:
values.update({
'url_src': snippet['thumbnails']['high']['url'],
'title': snippet['title'],
'description': snippet['description']
})
return values
values.update({
slide_metadata.update({
'name': snippet['title'],
'image_1920': self._fetch_data(snippet['thumbnails']['high']['url'], {}, 'image')['values'],
'description': snippet['description'],
'mime_type': False,
})
return {'values': values}
def _extract_google_error_message(self, error):
"""
See here for Google error format
https://developers.google.com/drive/api/v3/handle-errors
"""
try:
error = json.loads(error)
error = (error.get('error', {}).get('errors', []) or [{}])[0].get('reason')
except json.decoder.JSONDecodeError:
error = str(error)
thumbnail_url = snippet['thumbnails']['high']['url']
if image_url_only:
slide_metadata['image_url'] = thumbnail_url
else:
slide_metadata['image_1920'] = base64.b64encode(
requests.get(thumbnail_url, timeout=3).content
)
if error == 'keyInvalid':
return _('Your Google API key is invalid, please update it in your settings.\nSettings > Website > Features > API Key')
return slide_metadata, None
return _('Could not fetch data from url. Document or access right not available:\n%s', error)
def _fetch_google_drive_metadata(self, image_url_only=False):
""" Fetches document / video metadata from the Google Drive API.
@api.model
def _parse_google_document(self, document_id, only_preview_fields):
def get_slide_category(vals):
# TDE FIXME: WTF ??
slide_category = 'presentation'
if vals.get('image_1920'):
image = Image.open(io.BytesIO(base64.b64decode(vals['image_1920'])))
width, height = image.size
if height > width:
return 'document'
return slide_category
Returns a dict containing metadata with the following keys (matching slide.slide fields):
- 'name' matching the external file title
- 'image_1920' binary data of the file thumbnail
OR 'image_url' containing an external link to the thumbnail when 'image_url_only' param is True
- 'completion_time' which is computed for 2 types of files:
- pdf files where we download the content and then use slide.slide#_get_completion_time_pdf()
- videos where we use the 'videoMediaMetadata' to extract the 'durationMillis'
:param image_url_only: if True, will return 'image_url' instead of binary data
Typically used when displaying a slide preview to the end user.
:return a tuple (values, error) containing the values of the slide and a potential error
(e.g: 'File could not be found') """
# Google drive doesn't use a simple API key to access the data, but requires an access
# token. However, this token is generated in module google_drive, which is not in the
# dependencies of website_slides. We still keep the 'key' parameter just in case, but that
# is probably useless.
params = {}
params['projection'] = 'BASIC'
if 'google.drive.config' in self.env:
access_token = self.env['google.drive.config'].get_access_token()
access_token = False
try:
access_token = self.env['google.drive.config'].get_access_token()
except (RedirectWarning, UserError):
pass # ignore and use the 'key' fallback
if access_token:
params['access_token'] = access_token
if not params.get('access_token'):
params['key'] = self.env['website'].get_current_website().website_slide_google_app_key
fetch_res = self._fetch_data('https://www.googleapis.com/drive/v2/files/%s' % document_id, params, "json")
if fetch_res.get('error'):
return {'error': self._extract_google_error_message(fetch_res.get('error'))}
error_message = False
try:
response = requests.get(
'https://www.googleapis.com/drive/v2/files/%s' % self.google_drive_id,
timeout=3,
params=params
)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
error_message = e.response.content
if 'application/json' in e.response.headers.get('content-type'):
json_response = e.response.json()
if json_response.get('error', {}).get('code') == 404:
# in case we don't find the file on GDrive, we want to give some feedback to our user
return {}, _('Your file could not be found on Google Drive, please check the link and/or privacy settings')
except requests.exceptions.ConnectionError as e:
error_message = str(e)
google_values = fetch_res['values']
if only_preview_fields:
return {
'url_src': google_values['thumbnailLink'],
'title': google_values['title'],
}
if not error_message:
response = response.json()
if response.get('error'):
error_message = response.get('error', {}).get('errors', [{}])[0].get('reason')
values = {
'name': google_values['title'],
'image_1920': self._fetch_data(google_values['thumbnailLink'].replace('=s220', ''), {}, 'image')['values'],
'mime_type': google_values['mimeType'],
'document_id': document_id,
if error_message:
_logger.warning('Could not fetch Google Drive metadata: %s', error_message)
return {}, error_message
google_drive_values = response
slide_metadata = {
'name': google_drive_values.get('title')
}
if google_values['mimeType'].startswith('video/'):
values['slide_category'] = 'video'
elif google_values['mimeType'].startswith('image/'):
values['binary_content'] = values['image_1920']
values['slide_category'] = 'infographic'
elif google_values['mimeType'].startswith('application/vnd.google-apps'):
values['slide_category'] = get_slide_category(values)
if 'exportLinks' in google_values:
values['binary_content'] = self._fetch_data(google_values['exportLinks']['application/pdf'], params, 'pdf')['values']
elif google_values['mimeType'] == 'application/pdf':
# TODO: Google Drive PDF document doesn't provide plain text transcript
values['binary_content'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values']
values['slide_category'] = get_slide_category(values)
return {'values': values}
if google_drive_values.get('thumbnailLink'):
# small trick, we remove '=s220' to get a higher definition
thumbnail_url = google_drive_values['thumbnailLink'].replace('=s220', '')
if image_url_only:
slide_metadata['image_url'] = thumbnail_url
else:
slide_metadata['image_1920'] = base64.b64encode(
requests.get(thumbnail_url, timeout=3).content
)
if self.slide_category == 'document':
sheet_mimetypes = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.google-apps.spreadsheet'
]
doc_mimetypes = [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.oasis.opendocument.text',
'application/vnd.google-apps.document'
]
slides_mimetypes = [
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.google-apps.presentation'
]
mime_type = google_drive_values.get('mimeType')
if mime_type == 'application/pdf':
slide_metadata['slide_type'] = 'pdf'
if google_drive_values.get('downloadUrl'):
# attempt to download PDF content to extract a completion_time based on the number of pages
try:
pdf_response = requests.get(google_drive_values.get('downloadUrl'), timeout=5)
completion_time = self._get_completion_time_pdf(pdf_response.content)
if completion_time:
slide_metadata['completion_time'] = completion_time
except Exception:
pass # fail silently as this is nice to have
elif mime_type in sheet_mimetypes:
slide_metadata['slide_type'] = 'sheet'
elif mime_type in doc_mimetypes:
slide_metadata['slide_type'] = 'doc'
elif mime_type in slides_mimetypes:
slide_metadata['slide_type'] = 'slides'
elif mime_type and mime_type.startswith('image/'):
# image and videos should be input using another "slide_category" but let's be nice and
# assign them a matching slide_type
slide_metadata['slide_type'] = 'image'
elif mime_type and mime_type.startswith('video/'):
slide_metadata['slide_type'] = 'google_drive_video'
elif self.slide_category == 'video':
completion_time = float(
google_drive_values.get('videoMediaMetadata', {}).get('durationMillis', 0)
) / (60 * 60 * 1000) # millis to hours conversion
if completion_time:
slide_metadata['completion_time'] = completion_time
return slide_metadata, None
def _default_website_meta(self):
res = super(Slide, self)._default_website_meta()
@@ -913,6 +1094,20 @@ class Slide(models.Model):
# Data / Misc
# ---------------------------------------------------------
def _get_completion_time_pdf(self, data_bytes):
""" For PDFs, we assume that it takes 5 minutes to read a page.
This method receives the data of the PDF as bytes. """
if data_bytes.startswith(b'%PDF-'):
try:
pdf = PyPDF2.PdfFileReader(io.BytesIO(data_bytes), overwriteWarnings=False)
return (5 * len(pdf.pages)) / 60
except Exception:
pass # as this is a nice to have, fail silently
return False
def get_backend_menu_id(self):
return self.env.ref('website_slides.website_slides_menu_root').id
@@ -512,14 +512,22 @@
slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : "";
} else if (slideData.category === 'infographic') {
slideData.embedUrl = _.str.sprintf('/web/image/slide.slide/%s/image_1024', slideData.id);
} else if (_.contains(['document', 'presentation'], slideData.category)) {
} else if (slideData.category === 'document') {
slideData.embedUrl = $(slideData.embedCode).attr('src');
}
// fill empty property to allow searching on it with _.filter(list, matcher)
slideData.isQuiz = !!slideData.isQuiz;
slideData.hasQuestion = !!slideData.hasQuestion;
// technical settings for the Fullscreen to work
slideData._autoSetDone = _.contains(['infographic', 'presentation', 'document', 'webpage'], slideData.category) && !slideData.hasQuestion;
var autoSetDone = false;
if (!slideData.hasQuestion) {
if (_.contains(['infographic', 'document', 'webpage'], slideData.category)) {
autoSetDone = true; // images, documents (local + external) and web pages are marked as completed when opened
} else if (slideData.category === 'video' && slideData.videoSourceType === 'google_drive') {
autoSetDone = true; // google drive videos do not benefit from the YouTube integration and are marked as completed when opened
}
}
slideData._autoSetDone = autoSetDone;
});
return slidesDataList;
},
@@ -563,7 +571,7 @@
}
// render slide content
if (_.contains(['document', 'presentation', 'infographic'], slide.category)) {
if (_.contains(['document', 'infographic'], slide.category)) {
$content.html(QWeb.render('website.slides.fullscreen.content', {widget: this}));
} else if (slide.category === 'video') {
this.videoPlayer = new VideoPlayer(this, slide);
@@ -627,7 +635,7 @@
return self._renderSlide();
}).then(function() {
if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call
if (['document', 'presentation'].includes(slide.category)) {
if (slide.category === 'document') {
// only set the slide as completed after iFrame is loaded to avoid concurrent execution with 'embedUrl' controller
self.el.querySelector('iframe.o_wslides_iframe_viewer').addEventListener('load', () => self._setCompleted(slide.id));
} else {
@@ -12,7 +12,10 @@ var SlideUploadDialog = Dialog.extend({
'click .o_wslides_js_upload_install_button': '_onClickInstallModule',
'click .o_wslides_select_category': '_onClickSlideCategoryIcon',
'change input#upload': '_onChangeSlideUpload',
'change input#url': '_onChangeSlideUrl',
'change input#video_url': '_onChangeVideoUrl',
'change input#image_google_url': '_onChangeImageUrl',
'change input#document_google_url': '_onChangeDocumentUrl',
'change input[name="source_type"]': '_onChangeSourceType',
}),
/**
@@ -24,7 +27,7 @@ var SlideUploadDialog = Dialog.extend({
*/
init: function (parent, options) {
options = _.defaults(options || {}, {
title: _t("Upload a document"),
title: _t("Upload new content"),
size: 'medium',
});
this._super(parent, options);
@@ -99,11 +102,12 @@ var SlideUploadDialog = Dialog.extend({
});
}));
},
_fetchUrlPreview: function (url) {
_fetchUrlPreview: function (url, slideCategory) {
return this._rpc({
route: '/slides/prepare_preview/',
params: {
'url': url,
'slide_category': slideCategory,
'channel_id': this.channelID
},
});
@@ -126,46 +130,47 @@ var SlideUploadDialog = Dialog.extend({
* @private
*/
_formValidateGetValues: async function (forcePublished) {
var canvas = this.$('#data_canvas')[0];
var values = _.extend({
'channel_id': this.channelID,
'name': this._formGetFieldValue('name'),
'url': this._formGetFieldValue('url'),
'description': this._formGetFieldValue('description'),
'duration': this._formGetFieldValue('duration'),
'is_published': forcePublished,
}, this._getSelect2DropdownValues()); // add tags and category
var slideCategory = 'document';
// default slide_category (for webpage for instance)
if (_.contains(this.slide_category_data), this.get('state')) {
values['slide_category'] = this.get('state');
slideCategory = this.get('state');
}
var sourceType = 'local_file';
if (slideCategory === 'video') {
sourceType = 'external'; // force external for videos
} else {
sourceType = this.$('input[name="source_type"]:checked').data('value');
}
var values = _.extend({
'channel_id': this.channelID,
'description': this._formGetFieldValue('description'),
'document_google_url': this._formGetFieldValue('document_google_url'),
'duration': this._formGetFieldValue('duration'),
'image_google_url': this._formGetFieldValue('image_google_url'),
'is_published': forcePublished,
'name': this._formGetFieldValue('name'),
'slide_category': slideCategory,
'source_type': sourceType,
'video_url': this._formGetFieldValue('video_url'),
}, this._getSelect2DropdownValues()); // add tags and category
var canvas = this.$('#data_canvas')[0];
if (this.file.type === 'application/pdf') {
_.extend(values, {
'image_1920': canvas.toDataURL().split(',')[1],
'slide_category': canvas.height > canvas.width ? 'document' : 'presentation',
'mime_type': this.file.type,
'slide_category': 'document',
'binary_content': this.file.data
});
} else if (values['slide_category'] === 'webpage') {
_.extend(values, {
'mime_type': 'text/html',
'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data,
'image_1920': this.file.type === 'image/svg+xml' ? await this._svgToPng() : this.file.data,
});
} else if (/^image\/.*/.test(this.file.type)) {
const fileData = this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data;
if (values['slide_category'] === 'presentation') {
_.extend(values, {
'slide_category': 'infographic',
'mime_type': this.file.type === 'image/svg+xml' ? 'image/png' : this.file.type,
'binary_content': fileData,
});
} else {
_.extend(values, {
'image_1920': fileData,
});
}
_.extend(values, {
'slide_category': 'infographic',
'binary_content': this.file.type === 'image/svg+xml' ? await this._svgToPNG() : this.file.data,
});
}
return values;
},
@@ -327,10 +332,15 @@ var SlideUploadDialog = Dialog.extend({
*/
_setup: function () {
this.slide_category_data = {
presentation: {
document: {
icon: 'fa-file-pdf-o',
label: _t('Presentation'),
template: 'website.slide.upload.modal.presentation',
label: _t('Document'),
template: 'website.slide.upload.modal.document',
},
infographic: {
icon: 'fa-file-image-o',
label: _t('Infographic'),
template: 'website.slide.upload.modal.infographic',
},
webpage: {
icon: 'fa-file-text',
@@ -378,6 +388,68 @@ var SlideUploadDialog = Dialog.extend({
return png.split(',')[1];
},
/**
* When the URL changes for slides of categories infographic, document and video, we attempt to fetch
* some metadata on YouTube / Google Drive (such as a name, a title, a duration, ...).
*
* @param {string} url
* @param {string} slideCategory
*/
_updateSlideMetadata: function (url, slideCategory) {
var self = this;
this._alertRemove();
this.isValidUrl = false;
this.set('can_submit_form', false);
this._setModalLoading(true);
this._fetchUrlPreview(url, slideCategory).then(function (data) {
self.set('can_submit_form', true);
if (data.error) {
self._alertDisplay(data.error);
self._hidePreviewColumn();
} else {
self.isValidUrl = true;
if (data.name) {
self._formSetFieldValue('name', data.name);
}
if (data.description) {
self._formSetFieldValue('description', data.description);
}
if (data.completion_time) {
// hours to minutes conversion
self._formSetFieldValue('duration', Math.round(data.completion_time * 60));
}
if (data.image_url) {
self.$('#slide-image').attr('src', data.image_url);
}
if (!data.name && !data.description && !data.image_url) {
self._hidePreviewColumn();
} else {
self._showPreviewColumn();
}
}
self._setModalLoading(false);
});
},
/**
* Typically used when loading slide metadata.
* Since the request result will change form values, it's better to wait for the return of the
* request to avoid having the user type text that will be overridden (such as the slide name
* for example).
*
* @param {boolean} loading true to mask the modal with a loading screen, false to remove it
*/
_setModalLoading: function (loading) {
if (loading) {
this.$el.closest('.modal-content').append(QWeb.render('website.slide.upload.modal.loading'));
} else {
this.$el.closest('.modal-content').find('.o_wslides_slide_upload_loading').remove();
}
},
//--------------------------------------------------------------------------
// Handler
//--------------------------------------------------------------------------
@@ -404,7 +476,7 @@ var SlideUploadDialog = Dialog.extend({
if (currentType === '_import') {
this.set_title(_t("New Certification"));
} else {
this.set_title(_t("Upload a document"));
this.set_title(_t("Upload new content"));
}
},
_onChangeCanSubmitForm: function (ev) {
@@ -414,6 +486,28 @@ var SlideUploadDialog = Dialog.extend({
this.$('.o_w_slide_upload').button('loading');
}
},
/**
* When the user selects 'local_file' or 'external' as source type, we display the 'upload'
* field or the 'document_google_url' / 'image_google_url' fields respectively.
* We also toggle the 'required' attribute the same way.
*
* @param {Event} ev the onchange event
*/
_onChangeSourceType: function (ev) {
if (this.$('#source_type_local_file').is(':checked')) {
this.$('.o_wslides_js_slide_upload_local_file').removeClass('d-none');
this.$('.o_wslides_js_slide_upload_external').addClass('d-none');
this.$('#upload').attr('required', 'required');
this.$('#document_google_url, #image_google_url').removeAttr('required');
} else if (this.$('#source_type_external').is(':checked')) {
this.$('.o_wslides_js_slide_upload_external').removeClass('d-none');
this.$('.o_wslides_js_slide_upload_local_file').addClass('d-none');
this.$('#document_google_url, #image_google_url').attr('required', 'required');
this.$('#upload').removeAttr('required');
}
},
_onChangeSlideUpload: function (ev) {
var self = this;
this._alertRemove();
@@ -519,31 +613,15 @@ var SlideUploadDialog = Dialog.extend({
}
}
},
_onChangeSlideUrl: function (ev) {
var self = this;
var url = $(ev.target).val();
this._alertRemove();
this.isValidUrl = false;
this.set('can_submit_form', false);
this._fetchUrlPreview(url).then(function (data) {
self.set('can_submit_form', true);
if (data.error) {
self._alertDisplay(data.error);
} else {
if (data.completion_time) {
// hours to minutes conversion
self._formSetFieldValue('duration', Math.round(data.completion_time * 60));
}
self.$('#slide-image').attr('src', data.url_src);
self._formSetFieldValue('name', data.title);
self._formSetFieldValue('description', data.description);
self.isValidUrl = true;
self._showPreviewColumn();
}
});
_onChangeDocumentUrl: function (ev) {
this._updateSlideMetadata($(ev.target).val(), 'document');
},
_onChangeImageUrl: function (ev) {
this._updateSlideMetadata($(ev.target).val(), 'infographic');
},
_onChangeVideoUrl: function (ev) {
this._updateSlideMetadata($(ev.target).val(), 'video');
},
_onClickInstallModule: function (ev) {
var $btn = $(ev.currentTarget);
var moduleId = $btn.data('moduleId');
@@ -45,8 +45,8 @@ tour.register('slides_tour', {
content: Markup(_t("Your first section is created, now it's time to add lessons to your course. Click on <b>Add Content</b> to upload a document, create a web page or link a video.")),
position: 'bottom',
}, {
trigger: 'a[data-slide-category="presentation"]',
content: Markup(_t("First, let's add a <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 {
@@ -1,7 +1,7 @@
<templates id="template" xml:space="preserve">
<t t-name="website.slides.fullscreen.content">
<t t-if="_.contains(['document', 'presentation'], widget.get('slide').category)">
<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>
@@ -84,14 +84,33 @@
<!--
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">
Local File
</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">
External
</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"/>
</div>
<canvas id="data_canvas" class="d-none"></canvas>
<t t-call="website.slide.upload.modal.common"/>
@@ -99,15 +118,91 @@
<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 choose a local file from your computer or insert a valid 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>
</div>
</form>
</div>
</t>
<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">
Local File
</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">
External
</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"/>
</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 choose a local file from your computer or insert a valid 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>
@@ -128,7 +223,7 @@
<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>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 +238,8 @@
<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" placeholder="Youtube or Google Drive Link" required="required"/>
</div>
<canvas id="data_canvas" class="d-none"></canvas>
<t t-call="website.slide.upload.modal.common"/>
@@ -153,12 +248,20 @@
<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 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>
</div>
</div>
@@ -179,7 +282,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 +291,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>
+2 -2
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_category': '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_category': 'presentation',
'slide_category': 'document',
'is_published': True,
'completion_time': 3.0,
'sequence': 3,
+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_category': '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_category': '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_urls(self):
youtube_urls = {
'W0JQcpGLSFw': [
'https://youtu.be/W0JQcpGLSFw',
'https://www.youtube.com/watch?v=W0JQcpGLSFw',
@@ -188,9 +188,27 @@ 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)
# test URL from Google Drive when user hits the "share" button (main use case)
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': 'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing',
'slide_category': 'video'
})
self.assertEqual('google_drive', slide.video_source_type)
self.assertEqual('1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR', slide.google_drive_id)
@@ -42,7 +42,6 @@ class TestChannelStatistics(common.SlidesCase):
# 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_category == 'infographic')))
self.assertEqual(channel_publisher.nbr_presentation, len(channel_publisher.slide_content_ids.filtered(lambda s: s.slide_category == 'presentation')))
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
@@ -158,9 +157,6 @@ class TestSlideStatistics(common.SlidesCase):
@users('user_officer')
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_category == 'presentation')))
self.assertEqual(
category.nbr_document,
len(category.channel_id.slide_ids.filtered(lambda s: s.category_id == category and s.slide_category == 'document')))
@@ -34,7 +34,7 @@ class TestUICommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal):
'name': 'Gardening: The Know-How',
'sequence': 1,
'binary_content': pdf_content,
'slide_category': 'presentation',
'slide_category': 'document',
'is_published': True,
'is_preview': True,
}), (0, 0, {
@@ -117,13 +117,32 @@
<field name="active" invisible="1"/>
<field name="channel_id"/>
<field name="slide_category" string="Content Type"/>
<field name="url" attrs="{
'required': [('slide_category', 'in', ('video'))],
'invisible': [('slide_category', '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="binary_content" string="Attachment"
attrs="{'invisible': [('slide_category', 'not in', ('document', 'presentation'))]}"/>
<field name="slide_type" invisible="1"/>
<field name="source_type" widget="radio" 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')]}"
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 +152,7 @@
<span> hours</span>
</div>
<field name="is_preview"/>
<field name="slide_resource_downloadable" attrs="{'invisible': [('slide_category', '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>
@@ -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"
@@ -680,8 +680,8 @@
<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">
@@ -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,16 @@
<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_category == 'document'" t-att-class="'fa fa-file-pdf-o %s' % icon_class"></i>
<i t-if="slide.slide_category == 'presentation'" t-att-class="'fa fa-file-pdf-o %s' % icon_class"></i>
<i t-if="slide.slide_category == 'infographic'" t-att-class="'fa fa-file-picture-o %s' % icon_class"></i>
<i t-if="slide.slide_category == 'video'" t-att-class="'fa fa-play-circle %s' % icon_class"></i>
<i t-if="slide.slide_category == 'link'" t-att-class="'fa fa-file-code-o %s' % icon_class"></i>
<i t-if="slide.slide_category == 'webpage'" t-att-class="'fa fa-file-text %s' % icon_class"></i>
<i t-if="slide.slide_category == 'quiz'" t-att-class="'fa fa-question-circle %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 == 'webpage'" 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>
@@ -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')">
@@ -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_category 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', 'webpage', '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 '#'">
@@ -257,10 +255,10 @@
<div class="o_wslides_lesson_content_type">
<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_category 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_category == '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_category == 'webpage'" class="bg-white p-3">
@@ -557,7 +555,7 @@
<t t-esc="slide.embed_code_external"/>
</textarea>
</div>
<div t-if="slide.slide_category 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_category 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">
@@ -89,7 +89,7 @@
</t>
</div>
<!-- Fixed bottom navbar -->
<div id="PDFViewerNav" class="pt-2 pb-2 bg-light text-white" role="navigation" t-if="slide.slide_category 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">
@@ -110,11 +110,12 @@
t-att-data-can-access="can_access"
t-att-data-name="slide.name"
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_category 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">
@@ -124,6 +125,7 @@
<div class="ml-2">
<a t-if="can_access" class="d-block pt-1" 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>
@@ -162,6 +164,7 @@
<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-category="slide.slide_category"
t-att-data-slug="slug(slide)"
@@ -33,6 +33,9 @@ class Slide(models.Model):
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)
@@ -46,6 +49,13 @@ class Slide(models.Model):
if self.survey_id:
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)
@@ -20,7 +20,7 @@
<template id="slide_icon_inherit_survey" inherit_id="website_slides.slide_icon">
<xpath expr="//i[last()]" position="after">
<i t-if="slide.slide_category == 'certification'" t-att-class="'fa fa-trophy %s' % icon_class"></i>
<i t-if="slide.slide_type == 'certification'" t-att-class="'fa fa-trophy %s' % icon_class"></i>
</xpath>
</template>