[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:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user